From 96b37ea53833490a8749a60ac89e16e52e550b67 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:07:16 -0700 Subject: [PATCH 01/42] Build(deps): Bump azure/login from 3.0.2 to 3.1.0 (#820) Validated the unchanged OIDC inputs, Node 24 action runtime, default client-ID masking, workflow signing boundaries, core tests, distribution build, and exact-head required GitHub checks. The tag-only Azure signing workflow was not executed locally. AI-assisted dependency review and merge by Codex for the maintainer-authorized sweep. --- .github/workflows/release-engine.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-engine.yml b/.github/workflows/release-engine.yml index d86ead621..350b8b7f7 100644 --- a/.github/workflows/release-engine.yml +++ b/.github/workflows/release-engine.yml @@ -75,7 +75,7 @@ jobs: name: unsigned-windows-x64 path: unsigned - name: Azure login (OIDC) - uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3 + uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3 with: client-id: ${{ vars.AZURE_CLIENT_ID }} tenant-id: ${{ vars.AZURE_TENANT_ID }} From 2149fcce39a90bb409df5f16515f316a76dc6199 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:15:51 -0700 Subject: [PATCH 02/42] Build(deps-dev): Update Bun dependencies while preserving the AI tool-loop hold (#819) Retain eight dependency updates and ai 7.0.69 with its private provider stack. Validated frozen install, Rust release build/workspace tests, full default suite including engine oracle, extension/VS Code packaging, new-work browser tests and exact-head GitHub checks. The additional full live sweep has one inherited orphan-session cleanup failure, reproduced identically on unchanged main and the candidate; 35 other tests pass. Provider-backed behavior remains unverified and the known ai regression hold is preserved. AI-assisted dependency review, minimal fix, and merge by Codex for the maintainer-authorized sweep. --- bun.lock | 60 +++++++++++++++++++++++++--------------------------- package.json | 2 +- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/bun.lock b/bun.lock index 7d9782e1d..557dd4746 100644 --- a/bun.lock +++ b/bun.lock @@ -9,7 +9,7 @@ "@ai-sdk/google": "^4.0.8", "@ai-sdk/openai": "^4.0.7", "@anthropic-ai/claude-agent-sdk": "^0.3.165", - "@anthropic-ai/sdk": "^0.123.0", + "@anthropic-ai/sdk": "^0.125.0", "@babel/parser": "^8.0.4", "ai": "^7.0.14", "archiver": "^8.0.0", @@ -28,47 +28,51 @@ }, }, "packages": { - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@4.0.49", "", { "dependencies": { "@ai-sdk/provider": "4.0.10", "@ai-sdk/provider-utils": "5.0.36" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fzy2sLrs3vNsliIgx65fuovsa6a7OTXd6DllKUgLuVmPyqLnvoZCv9NlKgq+krzDzSxLb9d0zTaJJpsjBVLNyA=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@4.0.52", "", { "dependencies": { "@ai-sdk/provider": "4.0.13", "@ai-sdk/provider-utils": "5.0.39" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-IQt/y++kSUdqa9EbFOJntxLGjZMURTm/MnTUikNBZ5X86K5l96t/BwsRsX/SrH5kx+7yRPrmJ1WBNlmE8quSVg=="], "@ai-sdk/gateway": ["@ai-sdk/gateway@4.0.55", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-7WP/nlDz2BkXFlZzwF3w5JgCvktyHC++LP4PZ4mQpbvxrq+M7OdNslevlkuddHhJ+62BZu4oiL/afXlsWOSJZQ=="], - "@ai-sdk/google": ["@ai-sdk/google@4.0.63", "", { "dependencies": { "@ai-sdk/provider": "4.0.10", "@ai-sdk/provider-utils": "5.0.36" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-SlKcqnN0oKC8JWahecs3IvFnIFgbrEE4i/bmom5xsPFUjkIVvXAJAhRzjfgfROSOOeyvouKUFPWJEjn11FWf3A=="], + "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@4.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="], - "@ai-sdk/openai": ["@ai-sdk/openai@4.0.58", "", { "dependencies": { "@ai-sdk/provider": "4.0.10", "@ai-sdk/provider-utils": "5.0.36" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-oO3vH0e8lhN3OrANukokI2rvUJV+RVwTwJ2y+eSK+IiKlauFza2Yyl/7hjErqkjOt/eMOobhNijo+CFsFt8T+g=="], + "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="], - "@ai-sdk/provider": ["@ai-sdk/provider@4.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-fX2ENAc7iDpZ+Wp4+Rk06Usn/Ys7dI9uAkGv0jlF6XVrW13NkRWx5Ou+U6lIM2E1fTLkCs16GGrUAaVvroag7A=="], + "@ai-sdk/google": ["@ai-sdk/google@4.0.67", "", { "dependencies": { "@ai-sdk/provider": "4.0.13", "@ai-sdk/provider-utils": "5.0.39" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-F+o3bRDz0ua2hJbpnuEsoNsKR11NT+nAhq1eZfaRJr2rzs5eKi/E9wJw8ZXeJx7NlYeBF6il5/+itTBdkM1kJg=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.36", "", { "dependencies": { "@ai-sdk/provider": "4.0.10", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MFXBn6XDyf37PNQAge/HTatPJE8Vmg/g/w4WPtjSV53jq8FKAzoaN5+43hsdQa9bqgN+/13jxug9ChvsG+godQ=="], + "@ai-sdk/openai": ["@ai-sdk/openai@4.0.65", "", { "dependencies": { "@ai-sdk/provider": "4.0.13", "@ai-sdk/provider-utils": "5.0.39" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rpk1Tv7TR0H338zTKmSVMxv4yo++HDOxioiuvBySb8cWkx2fS10tWl/5cEwL0diJbcV/+p3+9gpIOre52UoQJA=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.260", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.260", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.260", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.260", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.260", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.260", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.260", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.260", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.260" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-PmABtP4Rwd6l95itQrqzguv6rS9uACqikPB9g8BPeWRKZOpy3xpEOjJLYauof3BFk2wNZnfhr0Ttx8ttcZzq0w=="], + "@ai-sdk/provider": ["@ai-sdk/provider@4.0.13", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-sJvnbFIFLzmKFXIjfwS8XCOAj6puHCawItwvA9PBZgk2f/l/TiC4OSfK3ueDbUVXfRCOn5eJN97EIF10toIOmQ=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.260", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0af2gRe6+sk13yYNX2gdDhcO15Kj1qd8B7ZQlv8mDt2lA1xFhTJqIvRwgrCHeCWZryWTmoRgtMoAfJOhQ9yn1g=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.39", "", { "dependencies": { "@ai-sdk/provider": "4.0.13", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.29.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VIFp4Qv3j+V941crFB1y2VG5yeGbeLOFRxiPaZJETkkpo1H5WviLx6H5yRkFkrXIGxnTGygKsPgCAJG0X61Auw=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.260", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tnxzv1//5SBT+IVSfIchpOEsg6tv+FADE1a9fSXYI81317IRMpFCQC9rgqxlRtY1Nh401zRzFvQdTz9QR4pmig=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.268", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.268", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.268", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.268", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.268", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.268", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.268", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.268", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.268" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-24oj+nuSjSYF/yrZefXdjUIYPJ+OnbTNP5GkaSiOiY4lfPF9PPwtDviDp3hojYpgvL5wDKaueNWmbfc/DVVbWQ=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.260", "", { "os": "linux", "cpu": "arm64" }, "sha512-sfZVBdAnuflSs+ru7U1DxIgjd9HPDmgvtA8hKZROfgNt8i2CYBfDHe4pLXkk/kS3nlOR+peJnjGVTFHW52s1mQ=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.268", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LdWLd0osGaDWXX6iYtDjUBRiY5ORoTSPfdkGRo7egbTYumRhHQi/B7JmbGUMqfNxpmKgTI8YsW+YTniUsBEVsw=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.260", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZLMbeLHVjkq5hmnpWK1Q2qGAztSPrnTtV+ufdPNf9rzYTYEJdLvEHMqmqlFE2VStOrFEpa7feftT3rcbobBJEw=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.268", "", { "os": "darwin", "cpu": "x64" }, "sha512-MHu24qCzyRjnfiaXOUB5vwtNPUUTuv1T1dAovbF3IQhKoPANVVt7dYW4uGczcVwrrDjiYWd5R6NLnFrz2//mkQ=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.260", "", { "os": "linux", "cpu": "x64" }, "sha512-JR6MS8KeETQoxSaNtBFqCFV66QM+gsNeuWjXIhac4wXb19gRGiOcsCjBqQU8kadUYCBUabd6lKN2edwG6ETSXg=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.268", "", { "os": "linux", "cpu": "arm64" }, "sha512-IgvuEx9s1F+dhq8B9XqEoUGJCYK1d1yN4TkDILeWQ5tI/Y6fX7MKppF1GVgiRd1R7YI2L4IGBkFcry5gJAunkg=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.260", "", { "os": "linux", "cpu": "x64" }, "sha512-JL07je0d2g680Hbu0D9W4hGuZlUeQlhPQac+NPKTJAdJ21bH12JdaMO5QE9RDNIxjd1BaodqMOdTEhjrH1capQ=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.268", "", { "os": "linux", "cpu": "arm64" }, "sha512-qQcJ5iIepKU1eHy6KTtE0PUJ5DDtb+HkokcLpVRZeIowCF4RSbOso5siiO/TUFNwoSrIB7ri7ji6ge5juHYrrg=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.260", "", { "os": "win32", "cpu": "arm64" }, "sha512-Fixnzgzxc0W6uGAwlp/zhoKsY+oLwHwE7y57lV8JhbGWHzK2gWPA9Q1s6TrR8A9sKPhmmHy7BrgTUundH2y2cQ=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.268", "", { "os": "linux", "cpu": "x64" }, "sha512-mIvSW1iyZpdq+G7nv+GSLhPSLG3OyhWKbL2BZ2/Zn9VSgtKjwMtnINW19pU84D+J+heRlqKUMAx4EbH4DOHolw=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.260", "", { "os": "win32", "cpu": "x64" }, "sha512-relNUBdfUSHVYmB04Nle5zJDykdT+FFLwcgz/SJc87Tj68jKT4vRSTeoDrE32kdmwhjFQ15HvmLGib41b9+xTA=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.268", "", { "os": "linux", "cpu": "x64" }, "sha512-BguDX5ZRLHeEI7R+lKu0BI2iv8SmlMwZCNnqWRo2x79yRsdjD2Hcvjsj79HholtzD6YPA/R2USlxuT7Ygj9k0w=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.123.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.268", "", { "os": "win32", "cpu": "arm64" }, "sha512-p8Aj7iohK2TddsQZ+tNhG2w6np1MmdpPVqHpOuElab7Iu4OA3iOerc/21T+Z3fza/gOPaTV0nKiEIO0tbAQrLA=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.268", "", { "os": "win32", "cpu": "x64" }, "sha512-zT56NISgATcVk6hVLr/CIl+5H/G6mqjhd1t/V/JMVupMuc3ffirEPu47cVJldGf0DVMa+sZ17uRwXvIM2Vi4iA=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.125.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-Hq5wYlXupzJ9M1Fzqjqa3hObcuigEXVZJqSYDgeZGM3wF4qrNERM5Z1OOAeoT9rlod8awJ3uYyJjbQXp1ckIGg=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], - "@babel/parser": ["@babel/parser@8.0.4", "", { "dependencies": { "@babel/types": "^8.0.4" }, "bin": "./bin/babel-parser.js" }, "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g=="], + "@babel/parser": ["@babel/parser@8.0.5", "", { "dependencies": { "@babel/types": "^8.0.5" }, "bin": "./bin/babel-parser.js" }, "sha512-51RXvQNFakaS0bTpYiGkxNbUVwkPO4kONv6EVLorZABxsx+KZ6Z7uSYvi/wmKS/+X+rfj9RvOw0/ZNh+cmI0Rw=="], "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - "@babel/types": ["@babel/types@8.0.4", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g=="], + "@babel/types": ["@babel/types@8.0.5", "", { "dependencies": { "@babel/helper-string-parser": "^8.0.0", "@babel/helper-validator-identifier": "^8.0.4" } }, "sha512-eVdMqi3ej5aHhyQ2Si6yD2cAWeV8FJK9UrhK5aL0Sd8hu5GhT+YswhVNbVheOGVYMg8kuGuMaUpkB3stjj4z8A=="], "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], @@ -116,6 +120,10 @@ "ai": ["ai@7.0.69", "", { "dependencies": { "@ai-sdk/gateway": "4.0.55", "@ai-sdk/provider": "4.0.7", "@ai-sdk/provider-utils": "5.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gudEqQYt/FuRpQkckLHPYKOh5M5v5ayPmzQAk5xyZOaWkUM0ArNGdXQiBGEuZA7GCwYOjzmWxB44ZW0CyRhHfQ=="], + "ai/@ai-sdk/provider": ["@ai-sdk/provider@4.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="], + + "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="], + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], @@ -250,8 +258,6 @@ "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], @@ -348,9 +354,9 @@ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "playwright": ["playwright@1.62.1", "", { "dependencies": { "playwright-core": "1.62.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg=="], + "playwright": ["playwright@1.63.0", "", { "dependencies": { "playwright-core": "1.63.0" }, "bin": { "playwright": "cli.js" } }, "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg=="], - "playwright-core": ["playwright-core@1.62.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw=="], + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], @@ -454,22 +460,14 @@ "zip-stream": ["zip-stream@7.0.5", "", { "dependencies": { "compress-commons": "^7.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w=="], - "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + "zod": ["zod@4.6.2", "", {}, "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "@ai-sdk/gateway/@ai-sdk/provider": ["@ai-sdk/provider@4.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="], - - "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="], - "@modelcontextprotocol/sdk/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], "@modelcontextprotocol/sdk/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "ai/@ai-sdk/provider": ["@ai-sdk/provider@4.0.7", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6or44XprPzKbr8zkmzosowSE0pxkvJcoojBL+mCZvPUt3kvXp3XSNqeVun9golb1acEfSo6yaEBRT18h2VU+1Q=="], - - "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@5.0.27", "", { "dependencies": { "@ai-sdk/provider": "4.0.7", "@standard-schema/spec": "^1.1.0", "@workflow/serde": "4.1.0", "eventsource-parser": "^3.0.8", "undici": "^7.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EzAn4pdgG5g0xXtH6lE2zyNmfjDQIDjATkfqzuidEI35g++hh4+07vnjzkT/RmGmIClPZiRj/Q2GMPV2V7mkHw=="], - "chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "eventsource/eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], diff --git a/package.json b/package.json index e469433ff..6dba50163 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "@ai-sdk/google": "^4.0.8", "@ai-sdk/openai": "^4.0.7", "@anthropic-ai/claude-agent-sdk": "^0.3.165", - "@anthropic-ai/sdk": "^0.123.0", + "@anthropic-ai/sdk": "^0.125.0", "@babel/parser": "^8.0.4", "ai": "^7.0.14", "archiver": "^8.0.0", From 1c043ea7c934fe584c2fa42a72d3e26d242f224b Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 14 Sep 2026 17:43:44 -0700 Subject: [PATCH 03/42] Simplify URL component escaping (#821) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace three duplicated UTF-8 escaping loops with one private encoder and explicit component character sets. Preserve existing URL behavior with a narrow characterization test. Prepared with AI assistance by Codex under pbakaus’s standing authorization for the daily architecture-simplification automation. --- crates/context/src/url.rs | 57 ++++++++++++++------------------------- 1 file changed, 20 insertions(+), 37 deletions(-) diff --git a/crates/context/src/url.rs b/crates/context/src/url.rs index 04ee35b45..addc661ec 100644 --- a/crates/context/src/url.rs +++ b/crates/context/src/url.rs @@ -21,43 +21,15 @@ fn is_forbidden_host_cp(c: char) -> bool { ) } -fn percent_encode_path(s: &str) -> String { - // path percent-encode set: C0 controls, space, ", #, <, >, ?, `, {, }, and non-ASCII - let mut out = String::new(); - for c in s.chars() { - let enc = (c as u32) <= 0x1f || (c as u32) >= 0x7f || matches!(c, ' ' | '"' | '#' | '<' | '>' | '?' | '`' | '{' | '}'); - if enc { - let mut buf = [0u8; 4]; - for b in c.encode_utf8(&mut buf).bytes() { - out.push_str(&format!("%{:02X}", b)); - } - } else { - out.push(c); - } - } - out -} +// Each component also escapes C0 controls and non-ASCII characters. +const PATH_ENCODE_SET: &[char] = &[' ', '"', '#', '<', '>', '?', '`', '{', '}']; +const QUERY_ENCODE_SET: &[char] = &[' ', '"', '#', '<', '>', '\'']; +const FRAGMENT_ENCODE_SET: &[char] = &[' ', '"', '<', '>', '`']; -fn percent_encode_query(s: &str) -> String { +fn percent_encode(s: &str, encode_set: &[char]) -> String { let mut out = String::new(); for c in s.chars() { - let enc = (c as u32) <= 0x1f || (c as u32) >= 0x7f || matches!(c, ' ' | '"' | '#' | '<' | '>' | '\''); - if enc { - let mut buf = [0u8; 4]; - for b in c.encode_utf8(&mut buf).bytes() { - out.push_str(&format!("%{:02X}", b)); - } - } else { - out.push(c); - } - } - out -} - -fn percent_encode_fragment(s: &str) -> String { - let mut out = String::new(); - for c in s.chars() { - let enc = (c as u32) <= 0x1f || (c as u32) >= 0x7f || matches!(c, ' ' | '"' | '<' | '>' | '`'); + let enc = (c as u32) <= 0x1f || (c as u32) >= 0x7f || encode_set.contains(&c); if enc { let mut buf = [0u8; 4]; for b in c.encode_utf8(&mut buf).bytes() { @@ -169,16 +141,16 @@ pub fn parse(input: &str) -> Option { segs.push(String::new()); } } else { - segs.push(percent_encode_path(seg)); + segs.push(percent_encode(seg, PATH_ENCODE_SET)); } } let pathname = if segs.is_empty() { "/".to_string() } else { format!("/{}", segs.join("/")) }; let search = match query { - Some(q) if !q.is_empty() => format!("?{}", percent_encode_query(q)), + Some(q) if !q.is_empty() => format!("?{}", percent_encode(q, QUERY_ENCODE_SET)), _ => String::new(), }; let hash = match hash { - Some(h) if !h.is_empty() => format!("#{}", percent_encode_fragment(h)), + Some(h) if !h.is_empty() => format!("#{}", percent_encode(h, FRAGMENT_ENCODE_SET)), _ => String::new(), }; Some(Url { scheme, username, password, hostname, port, pathname, search, hash }) @@ -238,6 +210,17 @@ impl Url { #[cfg(test)] mod tests { use super::*; + #[test] + fn component_encoding_preserves_distinct_sets() { + let text = "\u{1}\u{7f} é💡\"<>`{}'%2f"; + let u = parse(&format!("https://example.com/x{text}x?q=x{text}x#x{text}x")).unwrap(); + assert_eq!(u.pathname, "/x%01%7F%20%C3%A9%F0%9F%92%A1%22%3C%3E%60%7B%7D'%2fx"); + assert_eq!(u.search, "?q=x%01%7F%20%C3%A9%F0%9F%92%A1%22%3C%3E`{}%27%2fx"); + assert_eq!(u.hash, "#x%01%7F%20%C3%A9%F0%9F%92%A1%22%3C%3E%60{}'%2fx"); + let u = parse("https://example.com/a?x=?#h?#").unwrap(); + assert_eq!((u.pathname.as_str(), u.search.as_str(), u.hash.as_str()), ("/a", "?x=?", "#h?#")); + } + #[test] fn basics() { let u = parse("https://Impeccable.Style/docs/audit/").unwrap(); From fc89b0ed62325ced17ae69ebbdd4690f6161e2ce Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Sat, 5 Sep 2026 07:33:40 +0500 Subject: [PATCH 04/42] Add /impeccable generate: agent-initiated live variants (Node-era squash) Squash of the ten commits reviewed on PR #626, plus the last review round's connection-aware roll call, before the rebase onto the Rust engine: the generate command reference and router row, the overlay's agent-target handling (roll call, leases, replay, rescue), the Node-era live-server routes and live-generate CLI, the hook stand-down, the pricing cards e2e fixture, and the unit, contract, e2e, and skill-behavior tests. The server, CLI, hook, and pin halves are ported to the engine crates in the commits that follow. AI-assisted: implemented and tested with Claude Code under maintainer direction. Co-Authored-By: Claude Fable 5 --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- .github/workflows/ci.yml | 6 +- CLAUDE.md | 2 +- README.md | 7 +- README.npm.md | 2 +- scripts/lib/skill-categories.js | 1 + skill/SKILL.src.md | 3 +- skill/reference/generate.md | 92 ++ skill/reference/routing.md | 2 +- skill/scripts/command-metadata.json | 4 + skill/scripts/live-browser.js | 267 +++++- skill/scripts/live-generate.mjs | 227 +++++ .../files/index.html | 11 + .../files/package.json | 19 + .../files/src/App.jsx | 30 + .../files/src/main.jsx | 10 + .../files/src/styles.css | 10 + .../files/vite.config.js | 7 + .../vite8-react-pricing-cards/fixture.json | 41 + .../vite8-react-pricing-cards/gitignore.txt | 4 + tests/live-agent-target.test.mjs | 799 ++++++++++++++++++ tests/live-browser-source.test.mjs | 40 +- tests/live-e2e.test.mjs | 147 ++++ tests/live-reference.test.mjs | 11 + tests/skill-behavior/README.md | 3 + tests/skill-behavior/scenarios.test.mjs | 116 +++ 27 files changed, 1851 insertions(+), 16 deletions(-) create mode 100644 skill/reference/generate.md create mode 100644 skill/scripts/live-generate.mjs create mode 100644 tests/framework-fixtures/vite8-react-pricing-cards/files/index.html create mode 100644 tests/framework-fixtures/vite8-react-pricing-cards/files/package.json create mode 100644 tests/framework-fixtures/vite8-react-pricing-cards/files/src/App.jsx create mode 100644 tests/framework-fixtures/vite8-react-pricing-cards/files/src/main.jsx create mode 100644 tests/framework-fixtures/vite8-react-pricing-cards/files/src/styles.css create mode 100644 tests/framework-fixtures/vite8-react-pricing-cards/files/vite.config.js create mode 100644 tests/framework-fixtures/vite8-react-pricing-cards/fixture.json create mode 100644 tests/framework-fixtures/vite8-react-pricing-cards/gitignore.txt create mode 100644 tests/live-agent-target.test.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cbac542e3..16ec7c75a 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -2,7 +2,7 @@ "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "impeccable", "metadata": { - "description": "Design fluency for AI harnesses. 1 skill, 23 commands, and curated anti-patterns for impeccable frontend design." + "description": "Design fluency for AI harnesses. 1 skill, 24 commands, and curated anti-patterns for impeccable frontend design." }, "owner": { "name": "Paul Bakaus", @@ -11,7 +11,7 @@ "plugins": [ { "name": "impeccable", - "description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", + "description": "Design fluency for frontend development. 1 skill with 24 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", "version": "4.3.1", "author": { "name": "Paul Bakaus", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 565c74376..29dd28b41 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "impeccable", - "description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", + "description": "Design fluency for frontend development. 1 skill with 24 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", "version": "4.3.1", "author": { "name": "Paul Bakaus", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8c84e8e4..72c267f88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -346,7 +346,7 @@ jobs: - group: svelte fixtures: vite8-sveltekit-stateful - group: react - fixtures: vite8-react-css-modules,vite8-react-insert,vite8-react-plain + fixtures: vite8-react-css-modules,vite8-react-insert,vite8-react-plain,vite8-react-pricing-cards steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -397,7 +397,7 @@ jobs: run: bun run test:live-e2e env: IMPECCABLE_E2E_ONLY: ${{ matrix.fixtures }} - IMPECCABLE_E2E_SCENARIOS: core + IMPECCABLE_E2E_SCENARIOS: core,agent-target IMPECCABLE_E2E_TEST_TIMEOUT_MS: 180000 IMPECCABLE_E2E_INSTALL_TIMEOUT_MS: 120000 IMPECCABLE_E2E_DEV_READY_TIMEOUT_MS: 60000 @@ -428,7 +428,7 @@ jobs: - group: react-a fixtures: vite8-https,vite8-react-base-path,vite8-react-csp-meta,vite8-react-css-modules,vite8-react-emotion - group: react-b - fixtures: vite8-react-insert,vite8-react-mapped-list,vite8-react-modal,vite8-react-plain + fixtures: vite8-react-insert,vite8-react-mapped-list,vite8-react-modal,vite8-react-plain,vite8-react-pricing-cards - group: stateful fixtures: vite8-react-radix-dialog,vite8-react-router-spa,vite8-react-styled-components,vite8-react-tabs - group: styling diff --git a/CLAUDE.md b/CLAUDE.md index 8a95813bb..817c9fea5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Architecture (v3.0+) -There is **one** user-invocable skill, `impeccable`, with **23 commands** underneath it. Users type `/impeccable polish`, `/impeccable audit`, etc. The skill is defined in `skill/`: +There is **one** user-invocable skill, `impeccable`, with **24 commands** underneath it. Users type `/impeccable polish`, `/impeccable audit`, etc. The skill is defined in `skill/`: - `SKILL.src.md` — frontmatter (with the auto-trigger-optimized description and the `allowed-tools` list), shared design laws, and the **Commands** router table. Provider `SKILL.md` files are generated from this source. - `reference/` — one `.md` per command (`audit.md`, `polish.md`, `critique.md`, etc.), the shared playbooks the router loads outside the command table (`new-work.md`, `craft-floor.md`, `operate.md`, `routing.md`), and the native platform references (`ios.md`, `android.md`). When a sub-command is matched, the router loads its reference file. diff --git a/README.md b/README.md index c4b9831a5..b37c918d4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Impeccable -Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 61 deterministic detector rules for AI-generated frontend design. +Design guidance for AI coding agents. 1 skill, 24 commands, live browser iteration, and 61 deterministic detector rules for AI-generated frontend design. > **Quick start:** From your project root, run `npx impeccable install`, then run `/impeccable init` inside your AI coding tool. Full docs: [impeccable.style](https://impeccable.style). @@ -12,7 +12,7 @@ Every model trained on the same SaaS templates. Skip the guidance and you get th Impeccable adds: - **One setup flow.** `/impeccable init` records durable product truth in `PRODUCT.md`, so later commands know the audience, purpose, operating context, constraints, voice, and evidence without confusing those facts with surface-level visual direction. -- **23 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more. +- **24 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more. - **61 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key. ## What's Included @@ -33,7 +33,7 @@ Start every new project with: `init` inspects the project, asks only for material gaps in durable product truth, and writes `PRODUCT.md`. Visitor mode and visual direction are chosen later for each surface; incumbent or newly built visual systems are recorded separately in `DESIGN.md`. -### 23 Commands +### 24 Commands All commands are accessed through `/impeccable`: @@ -62,6 +62,7 @@ All commands are accessed through `/impeccable`: | `/impeccable adapt` | Adapt for different devices | | `/impeccable optimize` | Performance improvements | | `/impeccable live` | Visual variant mode: iterate on elements in the browser | +| `/impeccable generate` | Generate variants of a named element in the live browser, no manual picking | Use `/impeccable pin ` to create standalone shortcuts (e.g., `pin audit` creates `/audit`). diff --git a/README.npm.md b/README.npm.md index fc0133e47..f02d8085a 100644 --- a/README.npm.md +++ b/README.npm.md @@ -87,7 +87,7 @@ Binary lookup order: `IMPECCABLE_BIN`, the platform package, `~/.impeccable/bin/ ## Part of Impeccable -This CLI is part of [Impeccable](https://impeccable.style), a cross-provider design skill pack for AI-powered development tools. The full suite includes 23 commands for Claude, Cursor, GitHub Copilot, Gemini, Codex, Hermes Agent, Veto, and more. +This CLI is part of [Impeccable](https://impeccable.style), a cross-provider design skill pack for AI-powered development tools. The full suite includes 24 commands for Claude, Cursor, GitHub Copilot, Gemini, Codex, Hermes Agent, Veto, and more. ## License diff --git a/scripts/lib/skill-categories.js b/scripts/lib/skill-categories.js index 497797b5f..51d300369 100644 --- a/scripts/lib/skill-categories.js +++ b/scripts/lib/skill-categories.js @@ -37,6 +37,7 @@ export const SKILL_CATEGORIES = { document: 'system', extract: 'system', live: 'system', + generate: 'system', }; export const CATEGORY_ORDER = ['create', 'evaluate', 'refine', 'simplify', 'harden', 'system']; diff --git a/skill/SKILL.src.md b/skill/SKILL.src.md index f989e758c..3b9fe0d14 100644 --- a/skill/SKILL.src.md +++ b/skill/SKILL.src.md @@ -67,7 +67,8 @@ Choose the mode from the requested surface, not the product, and persist it only | `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) | | `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) | | `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) | -| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) | +| `live` | Iterate | Visual variant mode: pick elements in the browser, iterate on alternatives | [reference/live.md](reference/live.md) | +| `generate [n] [action] [element]` | Iterate | Variants, versions, or alternatives of a named element to choose from in the live browser; no manual picking | [reference/generate.md](reference/generate.md) | Routing: diff --git a/skill/reference/generate.md b/skill/reference/generate.md new file mode 100644 index 000000000..a12b2d9dd --- /dev/null +++ b/skill/reference/generate.md @@ -0,0 +1,92 @@ +> **Additional context needed**: only the target element, when the request does not name one that resolves uniquely on the page. + +Generate is a programmatic entry into live mode: the user names an element, a direction, and a count in one sentence, and you boot the live session, point the browser at the element, and the overlay scrolls to it, selects it, and fires the same Go a user click fires. Everything downstream is the standard live session. Read [live.md](live.md) in full now if you have not this session; this file is the entry ramp into its contract, and from Step 4 on you are inside it, with one deliberate divergence: Step 5 closes the session on its own once the accept lands, instead of staying open the way `live` does. + +**Web only.** Live mode's browser overlay has no native equivalent; on `ios` / `android` / `adaptive` projects, decline this command and offer `bolder` or `quieter` on the source instead. + +Three prohibitions cover the known ways this command goes wrong. Each names the tempting move first: + +- The poll shows no generate event yet, and writing variants straight into source feels faster. **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go), and the server refuses events for any other id; a missing event is fixed in Step 2 or Step 3, never with a direct source edit. +- Handing the user a link to click feels polite. **Open the page yourself** (Step 2); a pasted link usually means no page ever connects. +- The design hook may flag the preview scaffolding you just published. **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; `live-complete.mjs` verifies the file once the accepted variant is permanent. Current hooks stand down on the markers themselves; older installed hooks may still nag. + +## Step 1: Parse the request + +Three parts, all from the user's sentence: + +- **A number in the request**: that is the count. **No number**: 3. The protocol caps count at 8. +- **The direction wording** maps onto the live action vocabulary; never invent a new action value: + - **bold, bolder, stronger, punchier**: `bolder` + - **quiet, calmer, softer, toned down**: `quieter` + - **simpler, minimal, stripped**: `distill` + - **refined, tightened, polished**: `polish` + - **font and type words**: `typeset` + - **color words**: `colorize` + - **arrangement and spacing words**: `layout` + - **device and breakpoint words**: `adapt` + - **motion words**: `animate` + - **playful words**: `delight` + - **rule-breaking words**: `overdrive` + - **Nothing fits**: `impeccable`, with the user's wording passed as the prompt. + - **An action fits AND extra intent rides along** ("bolder, but keep it monochrome"): that action, with the rest as the prompt. +- **The element description** ("the pricing cards", "the hero heading"): Step 3 resolves it to a selector. + +Done when you hold an action from the vocabulary, a count from 1 to 8, and the element description. + +## Step 2: Boot live mode and open the page + +Run the boot exactly as [live.md](live.md)'s Start section describes: + +```bash +node {{scripts_path}}/live.mjs +``` + +**`config_missing` / `config_invalid`**: follow [live-setup.md](live-setup.md) first. + +Then open the app URL that serves a `pageFiles` entry (never `serverPort`; that is the helper, not the app): + +- **Cursor**: `browser_navigate` to the URL now; do not skip it. +- **Any other harness with a browser tool**: open the URL with that tool. +- **No browser tool exists in this harness**: tell the user the exact URL to open, and pass `--wait-for-browser 120000` in Step 3 so the command fires the moment their page connects. + +Done when the boot printed `"ok": true` and a page with the overlay is connected, which Step 3 proves by answering anything other than `no_browser_connected`. + +## Step 3: Target the element + +Derive the selector from project source, not from guesswork: an id first, then a unique class, then a landmark tag plus class. **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so scoped CSS restyles every instance at once. **Unsure the selector resolves uniquely**: probe with `--dry-run`; it resolves and reports without starting anything, and it works even mid-session. + +```bash +node {{scripts_path}}/live-generate.mjs --selector "section.pricing" --action bolder --count 3 +``` + +Flags: `--selector` (required), `--action`, `--count`, `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches), `--dry-run`, `--wait-for-browser `. + +Every verdict carries `_instructions` with the next move for that exact situation, with real values filled in; follow them over your recollection of this file. Two verdicts deserve naming because their fix sits outside the command: + +- **`no_browser_connected`**: Step 2's page is not actually open; open it yourself, then rerun. +- **`ambiguous`**: the candidates are listed in the output; target their common container, or rerun with `--text ""` or `--index `. + +Done when the verdict is `ok: true` with a `sessionId`: the browser has scrolled to the element, entered the picked state, and fired Go. + +## Step 4: Generate + +Start the poll loop per your harness policy in [live.md](live.md). The queued event for the returned `sessionId` is a standard `generate` event with the picked element's context and a preflighted scaffold; handle it exactly per live.md's Handle generate, which owns everything from planning to the done reply. + +Then tell the user, in one line, where their variants are: *"Three [bolder] variants are live on [the pricing cards]: cycle with the floating bar's arrows, adjust the Tune knobs, and Accept the keeper."* + +**Publishing variants does not end the session.** Keep servicing the poll; accept, discard, and carbonize cleanup follow live.md unchanged, and the helper server stays up through the accept. Done when live.md's contract marks the event you handled complete and the poll is running again. + +## Step 5: Close the session + +Generate is a one-shot command; this is where it diverges from an open-ended `live` session. Once the accept (or discard) completes, wrap up without being asked: carbonize cleanup is done and `live-complete.mjs` printed `phase: "completed"` (a discard needs no cleanup), so kill your background poll and run live.md's Cleanup: + +```bash +node {{scripts_path}}/live-server.mjs stop +``` + +Stopping removes the injected live script, and that removal reloads the page one last time: the user's browser now shows the accepted design with no overlay chrome, still served by their dev server. + +- **The user asks for more variants before you wrapped up**: skip the wrap-up, target the next element through the same session (Step 3, with `--dry-run` first when the selector is uncertain), and wrap up after the last accept. +- Restarting the dev server to freshen the page feels like tidying. **Never kill or restart the user's dev server**, including one you started in Step 2. It keeps serving the accepted source after wrap-up; a tab that still looks stale needs one hard refresh, not a new server. A relaunched server also hops to the next free port and strands every open tab on the dead one. + +Done when the helper is stopped, the stop output reported the script tag removed, and the dev site still answers with the accepted design. diff --git a/skill/reference/routing.md b/skill/reference/routing.md index 7f68c8681..fb35adc46 100644 --- a/skill/reference/routing.md +++ b/skill/reference/routing.md @@ -16,7 +16,7 @@ Reason over the signals; there is no score to obey: - `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique ` is a strong default. - `critique.latest` with a low `score` or non-zero `p0` / `p1` → `polish` (it reads that snapshot as its backlog and closes it when stale or cleared). - `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them. -- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code. +- `devServer.running` true → `live` is available for in-browser iteration, and `generate` for one-shot variant runs on a named element; if false, don't lead with either. **`live`, `generate`, and the bundled `impeccable detect` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with any of them; the browser overlay and the HTML rule engine don't apply to native app code. - Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`. **If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `{{scripts_path}}/impeccable detect --json ` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it. diff --git a/skill/scripts/command-metadata.json b/skill/scripts/command-metadata.json index dad8ef2e0..89891ea4d 100644 --- a/skill/scripts/command-metadata.json +++ b/skill/scripts/command-metadata.json @@ -19,6 +19,10 @@ "description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.", "argumentHint": "" }, + "generate": { + "description": "Agent-driven live variant generation. Boots live mode, finds the named element on the open page, scrolls the browser to it, and delivers N variants in the requested direction for the user to cycle and accept. Use for requests that name an element and a direction, like 'generate 3 bold variants of the pricing cards', skipping manual element picking.", + "argumentHint": "[count] [direction] variants of [element]" + }, "adapt": { "description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.", "argumentHint": "[target] [context (mobile, tablet, print...)]" diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index ac6f18586..e93a8bba9 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -2037,6 +2037,7 @@ function setLiveState(next) { state = next; window.__IMPECCABLE_LIVE_STATE__ = next; + retryDeclinedAgentTargets(); syncPageInteractionCursor(); // Whether a queued steer is still behind a generation is a function of this // state, so the hint has to move with it, not only with the 5s poll. @@ -4014,6 +4015,7 @@ function hidePendingApplyDock() { pendingApplyInFlight = false; + retryDeclinedAgentTargets(); clearStoredManualApplyState(); if (pendingIntroAnimation) { pendingIntroAnimation.cancel(); pendingIntroAnimation = null; } if (pendingDockEl) pendingDockEl.style.display = 'none'; @@ -4047,6 +4049,7 @@ function setPendingApplyLoading(loading, count) { if (!pendingPillEl || !pendingPillLabelEl || !pendingPillCountEl || !pendingTrashBtn) return; pendingApplyInFlight = loading === true; + if (!pendingApplyInFlight) retryDeclinedAgentTargets(); const currentCount = count || parseInt(pendingPillEl.dataset.count || '0', 10) || 0; if (pendingApplyInFlight) storeManualApplyState(currentCount); else clearStoredManualApplyState(); @@ -7112,6 +7115,262 @@ } // + // ------------------------------------------------------------------ + // Agent-initiated targeting (the `generate` command). The agent names an + // element by CSS selector over POST /agent-target; the server pushes an + // `agent_target` SSE message here. The overlay resolves the selector, + // scrolls the element into view, enters the same picked state a user + // click produces, and fires the normal Go pipeline, so everything + // downstream (generate event, variants, cycling, accept) is unchanged. + // The verdict goes back through POST /agent-target-result, which resolves + // the agent's held-open CLI call. + + function postAgentTargetResult(targetId, result) { + fetch('http://localhost:' + PORT + '/agent-target-result?token=' + TOKEN, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: TOKEN, targetId, ...result }), + }).catch(() => { /* server gone; nothing to report to */ }); + } + + function describeAgentTargetCandidate(el) { + return { + tag: el.tagName.toLowerCase(), + id: el.id || null, + classes: [...el.classList].filter((c) => !c.startsWith('impeccable-')), + text: (el.textContent || '').trim().slice(0, 80), + }; + } + + function resolveAgentTargetElement(msg) { + let matched; + try { + matched = [...document.querySelectorAll(msg.selector)]; + } catch { + return { error: { ok: false, error: 'invalid_selector', selector: msg.selector } }; + } + let candidates = matched.filter((el) => pickable(el)); + if (msg.text) { + const needle = String(msg.text).toLowerCase(); + candidates = candidates.filter((el) => (el.textContent || '').toLowerCase().includes(needle)); + } + if (candidates.length === 0) { + return { + error: { + ok: false, + error: 'no_match', + selector: msg.selector, + matchCount: 0, + // How many nodes the raw selector hit before the pickable/text + // filters: distinguishes a wrong selector from an unpickable match. + rawMatchCount: matched.length, + }, + }; + } + if (Number.isInteger(msg.index)) { + const el = candidates[msg.index - 1]; + if (!el) { + return { error: { ok: false, error: 'index_out_of_range', selector: msg.selector, matchCount: candidates.length } }; + } + return { el, matchCount: candidates.length }; + } + if (candidates.length > 1) { + return { + error: { + ok: false, + error: 'ambiguous', + selector: msg.selector, + matchCount: candidates.length, + candidates: candidates.slice(0, 8).map(describeAgentTargetCandidate), + }, + }; + } + return { el: candidates[0], matchCount: 1 }; + } + + function scrollAgentTargetIntoView(el, done) { + const rect = el.getBoundingClientRect(); + if (rect.top >= 0 && rect.bottom <= window.innerHeight) { done(); return; } + let settled = false; + let fallback = null; + const finish = () => { + if (settled) return; + settled = true; + removeEventListener('scrollend', finish, true); + if (fallback) clearTimeout(fallback); + done(); + }; + // scrollend where supported; a timer covers engines without it and the + // no-movement case (element already at its final resting position). + addEventListener('scrollend', finish, true); + fallback = setTimeout(finish, 1200); + el.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + + // One id per page load: the server keys claims and roll-call reports on + // it, and only the tab that holds the lease can renew it. + const AGENT_TARGET_CLIENT_ID = id8(); + + function claimAgentTarget(targetId, report) { + return fetch('http://localhost:' + PORT + '/agent-target-claim?token=' + TOKEN, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: TOKEN, targetId, clientId: AGENT_TARGET_CLIENT_ID, ...report }), + }).then((res) => res.json()) + .then((j) => ({ granted: !!j && j.granted === true, pending: !!j && j.pending === true })) + .catch(() => ({ granted: false, pending: false })); + } + + function agentTargetBusyReason() { + if (pendingApplyInFlight) return 'manual_apply_in_flight'; + if (state !== 'IDLE' && state !== 'PICKING' && state !== 'CONFIGURING') return 'session_active'; + return null; + } + + // Targets this tab declined as busy. A busy report is only this tab's word + // at that moment: the moment it is free again (setLiveState), it claims + // each of these as eligible, and the server drops the stale report, so a + // busy verdict is never built on a tab that has since gone idle. The + // server denies claims for resolved targets, so retries are harmless. + const busyDeclinedTargets = new Map(); + + function declineAgentTargetBusy(msg, busy) { + busyDeclinedTargets.set(msg.targetId, msg); + claimAgentTarget(msg.targetId, { eligible: false, state, reason: busy }); + } + + // A torn-down overlay, or one whose helper connection is gone, cannot + // serve a target and must not even claim one: it would hold the lease for + // a request it will never act on. + function agentTargetOverlayGone() { + return !evtSource; + } + + // A denied claimant retries at this cadence, a little over the lease, so + // the first retry after a dead holder's lease lapses is granted. + const AGENT_TARGET_RESCUE_RETRY_MS = 3500; + + // Claim the lease and act as the holder. A denied claim means another tab + // holds the lease. That holder can die before posting its result (reload, + // crash, even after renewing), and its lease lapses after ~3s, so this tab + // keeps retrying for as long as the server still holds the request: the + // answer's `pending` is the server's word that the request is alive, and + // it turns false the moment the request resolved or timed out, so no tab + // retries a request nobody awaits. A tab that turned busy meanwhile joins + // the roll call instead of taking a lease it cannot use. The first claim + // and the busy-to-idle re-claim share this. + function claimAndActOnAgentTarget(msg) { + if (agentTargetOverlayGone()) return; + const busy = agentTargetBusyReason(); + if (busy) { declineAgentTargetBusy(msg, busy); return; } + claimAgentTarget(msg.targetId, { eligible: true }).then((claim) => { + if (claim.granted) { actOnAgentTarget(msg); return; } + if (!claim.pending) return; + setTimeout(() => claimAndActOnAgentTarget(msg), AGENT_TARGET_RESCUE_RETRY_MS); + }); + } + + function retryDeclinedAgentTargets() { + if (busyDeclinedTargets.size === 0 || agentTargetBusyReason()) return; + for (const [targetId, msg] of busyDeclinedTargets) { + busyDeclinedTargets.delete(targetId); + claimAndActOnAgentTarget(msg); + } + } + + function handleAgentTarget(msg) { + if (!msg || typeof msg.targetId !== 'string') return; + const busy = agentTargetBusyReason(); + if (busy) { + // Roll call: a busy tab reports itself and never acts. The server + // answers `busy` the moment every connected overlay has reported, so + // an idle tab elsewhere is never raced by a timer. + declineAgentTargetBusy(msg, busy); + return; + } + // Eligible tabs race for the server's lease and only the holder acts. A + // hidden tab yields a short head start so a visible one wins when both + // exist, and still serves the request on its own: the user finds the + // selection waiting when they return to it. + setTimeout(() => claimAndActOnAgentTarget(msg), document.hidden ? 150 : 0); + } + + function actOnAgentTarget(msg) { + if (agentTargetOverlayGone()) return; + const reply = (result) => postAgentTargetResult(msg.targetId, result); + const busy = agentTargetBusyReason(); + if (busy) { + // Turned busy between claim and act: report it, which also hands the + // lease back so the roll call can complete or a rescuer can claim. + declineAgentTargetBusy(msg, busy); + return; + } + const resolved = resolveAgentTargetElement(msg); + if (resolved.error) { reply(resolved.error); return; } + const el = resolved.el; + if (msg.dryRun) { + reply({ + ok: true, + dryRun: true, + matchCount: resolved.matchCount, + element: describeAgentTargetCandidate(el), + }); + return; + } + scrollAgentTargetIntoView(el, () => { + // Torn down during the scroll settle: do not renew. The lease lapses + // for a rescuer instead of Go minting a session on a dismantled + // overlay. + if (agentTargetOverlayGone()) return; + // Renew the lease right before the irreversible part: a tab whose + // lease lapsed while it scrolled (a rescuer took over) stops here, so + // one request never gets two Go presses. + claimAgentTarget(msg.targetId, { eligible: true }).then((renewal) => { + if (!renewal.granted) return; + // An insert placement left mid-configure gives way, exactly as a + // click outside it does in handleClick. + if (state === 'CONFIGURING' && configureKind === 'insert') cancelInsertConfigure(); + // Mirror of the user-click pick entry in handleClick, minus the + // pick-mode gate (the agent's intent replaces the toggle); the entry + // goes through beginNewLiveConfiguration like every other pick so + // deferred recovery sees a fresh interaction revision. + selectedElement = el; + beginNewLiveConfiguration(); + showHighlight(selectedElement); + clearAnnotations(); + showAnnotOverlay(selectedElement); + showBar('configure'); + renderEditBadge(hasTextRows(selectedElement) ? 'idle' : 'hidden'); + startScrollTracking(); + maybePrefetchPage(); + maybeWarnConditionalAncestor(selectedElement); + // Preset what the agent asked for, then fire the same Go a user press + // fires. handleGo reads exactly these inputs. + selectedAction = msg.action; + selectedCount = msg.count; + // updateBarContent rebuilds the configure row and replaces the input + // element, so the prompt must be written into the input it creates, + // never before (the action-chip click handler does the same dance). + updateBarContent('configure'); + const input = uiGetById(PREFIX + '-input'); + if (input) input.value = msg.prompt || ''; + handleGo(); + if (state === 'GENERATING' && currentSessionId) { + reply({ + ok: true, + matchCount: resolved.matchCount, + sessionId: currentSessionId, + action: msg.action, + count: msg.count, + element: describeAgentTargetCandidate(el), + }); + } else { + reply({ ok: false, error: 'go_failed', state }); + } + }); + }); + } + // SSE (server→browser) + fetch POST (browser→server) // Zero-dependency replacement for WebSocket. // @@ -7121,7 +7380,7 @@ const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble function connectSSE() { - evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN); + evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN + '&clientId=' + AGENT_TARGET_CLIENT_ID); evtSource.onopen = () => { sseRetries = 0; // reset on successful (re)connect @@ -7146,6 +7405,9 @@ case 'agent_polling': syncAgentPollingUi(!!msg.connected); break; + case 'agent_target': + handleAgentTarget(msg); + break; case 'agent_phase': if (msg.id === currentSessionId && (state === 'GENERATING' || state === 'CYCLING')) { // Advance the visible phase monotonically. A behind/resumed @@ -11715,6 +11977,9 @@ void main() { /** Full teardown: remove all UI, disconnect SSE, clean up. */ function teardown() { + // Declined targets die with the overlay: the IDLE transition below must + // not re-claim a lease this page can no longer act on. + busyDeclinedTargets.clear(); stopAgentStatusPoll(); hideAgentPollTooltip(); if (agentPollTooltipEl) { diff --git a/skill/scripts/live-generate.mjs b/skill/scripts/live-generate.mjs new file mode 100644 index 000000000..8ed13c1ce --- /dev/null +++ b/skill/scripts/live-generate.mjs @@ -0,0 +1,227 @@ +#!/usr/bin/env node +/** + * Agent-initiated element targeting for the `generate` command. + * + * Asks the live overlay to find an element by CSS selector, scroll to it, + * enter the picked state, and fire the normal Go pipeline with the given + * action and count. On success the browser starts a standard generate + * session; the agent then handles the resulting `generate` event from the + * poll loop exactly as live.md describes. Requires a running live helper + * server (live.mjs boot) and an open page with the overlay attached. + * + * Usage: + * node /live-generate.mjs --selector "section.pricing" --action bolder --count 3 + * node /live-generate.mjs --selector ".card" --text "Studio" --action impeccable --prompt "warmer" + * + * Flags: + * --selector required; resolved with document.querySelectorAll + * --text optional; keeps only matches whose textContent contains it + * --index optional; 1-based pick among the remaining matches + * --action optional; one of the live action vocabulary (default: impeccable) + * --count optional; variants to request, 1-8 (default: 3) + * --prompt optional; freeform direction, same as typing before Go + * --dry-run optional; resolve and report without starting anything + * --wait-for-browser optional; poll the helper until a page with the + * overlay connects (or the budget runs out) before + * sending the target. For harnesses with no browser + * tool: hand the user the URL, run with this flag, and + * the command fires as soon as they open the page. + */ + +import process from 'node:process'; +import { enterLiveRoot } from './live/roots.mjs'; +import { VISUAL_ACTIONS } from './live/vocabulary.mjs'; +import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; + +enterLiveRoot(process.cwd()); + +// Destroy fetch's global undici dispatcher before process.exit(): a live +// keep-alive socket trips a libuv assertion on Windows/Node 24 after a +// successful print (nodejs/node#56645, issue #573), matching context.mjs. +async function destroyFetchDispatcher() { + const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')]; + if (dispatcher && typeof dispatcher.destroy === 'function') { + try { await dispatcher.destroy(); } catch { /* exit regardless */ } + } +} + +async function fail(payload) { + console.log(JSON.stringify(payload, null, 2)); + await destroyFetchDispatcher(); + process.exit(1); +} + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith('--')) continue; + const key = arg.slice(2); + if (key === 'dry-run') { args['dry-run'] = true; continue; } + const value = argv[i + 1]; + if (value === undefined || value.startsWith('--')) { + // Pre-fetch validation inside a sync helper: no socket can exist yet, + // so a plain synchronous exit is safe here. + console.log(JSON.stringify({ ok: false, error: 'missing_flag_value', flag: arg }, null, 2)); + process.exit(1); + } + args[key] = value; + i += 1; + } + return args; +} + +const args = parseArgs(process.argv.slice(2)); + +const selector = (args.selector || '').trim(); +if (!selector) { + await fail({ + ok: false, + error: 'selector_required', + _instructions: 'Pass --selector with a CSS selector for the element to target. Derive it from the page source: prefer an id, a unique class, or a landmark section, and add --text "" when the class repeats.', + }); +} + +const action = args.action || 'impeccable'; +if (!VISUAL_ACTIONS.includes(action)) { + await fail({ + ok: false, + error: 'invalid_action', + action, + validActions: VISUAL_ACTIONS, + _instructions: 'Map the request wording onto the closest listed action (bold -> bolder, quiet/calmer -> quieter, simplify -> distill). When no action fits, use --action impeccable and carry the wording via --prompt.', + }); +} + +const count = args.count === undefined ? 3 : Number(args.count); +if (!Number.isInteger(count) || count < 1 || count > 8) { + await fail({ ok: false, error: 'invalid_count', count: args.count, _instructions: 'Pass --count as an integer from 1 to 8.' }); +} + +let index; +if (args.index !== undefined) { + index = Number(args.index); + if (!Number.isInteger(index) || index < 1) { + await fail({ ok: false, error: 'invalid_index', index: args.index, _instructions: 'Pass --index as a 1-based integer position among the matches.' }); + } +} + +let waitForBrowserMs = 0; +if (args['wait-for-browser'] !== undefined) { + waitForBrowserMs = Number(args['wait-for-browser']); + if (!Number.isInteger(waitForBrowserMs) || waitForBrowserMs < 1) { + await fail({ ok: false, error: 'invalid_wait', wait: args['wait-for-browser'], _instructions: 'Pass --wait-for-browser as a positive integer of milliseconds, e.g. --wait-for-browser 120000.' }); + } +} + +const found = readLiveServerInfo(process.cwd()); +if (!found || !found.info || !found.info.port || !found.info.token) { + await fail({ + ok: false, + error: 'server_not_running', + _instructions: 'No live helper server is recorded for this project. Run the live boot first (node /live.mjs), open the app URL that serves a pageFiles entry, then rerun this command.', + }); +} + +const { port, token } = found.info; + +const INSTRUCTIONS = { + ok: (r) => (r.dryRun + ? `Dry run only: the selector resolves to one element (${r.element?.tag}${r.element?.id ? '#' + r.element.id : ''}) and no session was started. Rerun without --dry-run to generate.` + : `Session ${r.sessionId} started: the browser scrolled to the target and fired Go (action "${r.action}", count ${r.count}). Poll now with live-poll.mjs; the next event for this session is its generate event. Handle it exactly per live.md's Handle generate, then reply done and keep polling.`), + no_browser_connected: () => 'No page with the live overlay is connected. Open the app URL that serves a pageFiles entry yourself with your harness browser tool, then rerun this command. Only when no browser tool exists: give the user the URL and rerun with --wait-for-browser 120000 so the command fires as soon as they open the page.', + browser_timeout: () => 'The overlay did not answer in time. The page may be mid-reload: run live-status.mjs to check whether a session started anyway, reload the app page, then rerun this command.', + invalid_selector: () => 'The selector is not valid CSS. Fix the selector syntax and rerun.', + no_match: (r) => (r.rawMatchCount > 0 + ? `The selector hit ${r.rawMatchCount} node(s) but none is pickable (too small, chrome, or filtered by --text). Target a larger element or adjust --text.` + : 'The selector matched nothing on the open page. Derive a better selector from the page source (an id, a unique class, or a landmark), or add --text with a snippet of the element\'s visible text.'), + ambiguous: (r) => `The selector matched ${r.matchCount} elements. Either target their common container instead, or disambiguate with --text "" or --index <1-based position>. The candidates are listed in this output.`, + index_out_of_range: (r) => `--index is out of range: only ${r.matchCount} match(es). Use an index from 1 to ${r.matchCount}.`, + busy: (r) => `A live session is already mid-flight (browser state ${r.state}). Let the user finish or discard it in the browser, or handle the pending event in your poll loop, then rerun.`, + go_failed: (r) => `The overlay could not start generation from the picked state (browser state ${r.state}). Reload the app page and rerun this command.`, + server_stopping: () => 'The live helper server is shutting down. Re-run the live boot (live.mjs), reopen the page, then rerun this command.', +}; + +async function waitForBrowserConnection(budgetMs) { + const deadline = Date.now() + budgetMs; + for (;;) { + let status; + try { + const res = await fetch(`http://127.0.0.1:${port}/status?token=${token}`, { + signal: AbortSignal.timeout(5_000), + }); + status = await res.json(); + } catch (err) { + await fail({ + ok: false, + error: 'server_unreachable', + detail: err?.message, + _instructions: 'The recorded live server did not answer while waiting for a browser; it likely died. Re-run the live boot (node /live.mjs), reopen the app page, then rerun this command.', + }); + } + if ((status.connectedClients || 0) > 0) return; + if (Date.now() >= deadline) { + await fail({ + ok: false, + error: 'no_browser_connected', + waitedMs: budgetMs, + _instructions: INSTRUCTIONS.no_browser_connected(), + }); + } + await new Promise((r) => setTimeout(r, 1_000)); + } +} + +async function main() { + if (waitForBrowserMs > 0) await waitForBrowserConnection(waitForBrowserMs); + const body = { + token, + selector, + action, + count, + ...(args.text ? { text: args.text } : {}), + ...(index !== undefined ? { index } : {}), + ...(args.prompt ? { prompt: args.prompt } : {}), + ...(args['dry-run'] ? { dryRun: true } : {}), + }; + let res; + try { + res = await fetch(`http://127.0.0.1:${port}/agent-target`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + // Client-side cap just above the server's 15s hold, so a hung helper + // still fails fast. + signal: AbortSignal.timeout(20_000), + }); + } catch (err) { + const timedOut = err?.name === 'TimeoutError' || err?.name === 'AbortError'; + await fail({ + ok: false, + error: timedOut ? 'request_timeout' : 'server_unreachable', + detail: err?.message, + _instructions: timedOut + ? INSTRUCTIONS.browser_timeout() + : 'The recorded live server did not answer; it likely died. Re-run the live boot (node /live.mjs), reopen the app page, then rerun this command.', + }); + } + let result; + try { + result = await res.json(); + } catch { + await fail({ ok: false, error: 'bad_server_response', status: res.status }); + } + if (!res.ok) { + await fail({ ok: false, error: result.error || `http_${res.status}`, ...result }); + } + const instructions = INSTRUCTIONS[result.ok ? 'ok' : result.error]; + const output = { + ...result, + ...(instructions ? { _instructions: instructions(result) } : {}), + }; + console.log(JSON.stringify(output, null, 2)); + await destroyFetchDispatcher(); + process.exit(result.ok ? 0 : 1); +} + +main().catch((err) => fail({ ok: false, error: 'unexpected_failure', detail: err?.message })); diff --git a/tests/framework-fixtures/vite8-react-pricing-cards/files/index.html b/tests/framework-fixtures/vite8-react-pricing-cards/files/index.html new file mode 100644 index 000000000..28946e19d --- /dev/null +++ b/tests/framework-fixtures/vite8-react-pricing-cards/files/index.html @@ -0,0 +1,11 @@ + + + + + Vite 8 + Pricing Cards Fixture + + +
+ + + diff --git a/tests/framework-fixtures/vite8-react-pricing-cards/files/package.json b/tests/framework-fixtures/vite8-react-pricing-cards/files/package.json new file mode 100644 index 000000000..7c76ca389 --- /dev/null +++ b/tests/framework-fixtures/vite8-react-pricing-cards/files/package.json @@ -0,0 +1,19 @@ +{ + "name": "vite8-react-pricing-cards-fixture", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^6.0.0", + "vite": "^8.0.0" + } +} diff --git a/tests/framework-fixtures/vite8-react-pricing-cards/files/src/App.jsx b/tests/framework-fixtures/vite8-react-pricing-cards/files/src/App.jsx new file mode 100644 index 000000000..d6106fc24 --- /dev/null +++ b/tests/framework-fixtures/vite8-react-pricing-cards/files/src/App.jsx @@ -0,0 +1,30 @@ +const TIERS = [ + { id: 'starter', name: 'Starter', price: '$19/mo', blurb: 'For a single project and one seat.' }, + { id: 'studio', name: 'Studio', price: '$49/mo', blurb: 'For small teams shipping every week.' }, + { id: 'atelier', name: 'Atelier', price: '$120/mo', blurb: 'For agencies running many brands.' }, +]; + +export default function App() { + return ( +
+
+

A tall hero keeps the pricing far below the fold.

+

+ The agent-target scenario must scroll the pricing section into view on its own. +

+
+
+

Simple pricing

+
+ {TIERS.map((tier) => ( +
+

{tier.name}

+

{tier.price}

+

{tier.blurb}

+
+ ))} +
+
+
+ ); +} diff --git a/tests/framework-fixtures/vite8-react-pricing-cards/files/src/main.jsx b/tests/framework-fixtures/vite8-react-pricing-cards/files/src/main.jsx new file mode 100644 index 000000000..f2baba283 --- /dev/null +++ b/tests/framework-fixtures/vite8-react-pricing-cards/files/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.jsx'; +import './styles.css'; + +createRoot(document.getElementById('root')).render( + + + , +); diff --git a/tests/framework-fixtures/vite8-react-pricing-cards/files/src/styles.css b/tests/framework-fixtures/vite8-react-pricing-cards/files/src/styles.css new file mode 100644 index 000000000..274982224 --- /dev/null +++ b/tests/framework-fixtures/vite8-react-pricing-cards/files/src/styles.css @@ -0,0 +1,10 @@ +body { margin: 0; font-family: system-ui, sans-serif; color: #1d2229; } +.page { max-width: 960px; margin: 0 auto; padding: 0 24px; } +.hero { min-height: 160vh; display: flex; flex-direction: column; justify-content: center; } +.hero-heading { font-size: 40px; max-width: 18ch; } +.hero-hook { color: #55606e; } +.pricing { padding: 80px 0 120px; } +.pricing-title { font-size: 32px; } +.pricing-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; } +.pricing-card { border: 1px solid #d8dee7; border-radius: 10px; padding: 20px; } +.tier-price { font-weight: 700; } diff --git a/tests/framework-fixtures/vite8-react-pricing-cards/files/vite.config.js b/tests/framework-fixtures/vite8-react-pricing-cards/files/vite.config.js new file mode 100644 index 000000000..f7ad4b565 --- /dev/null +++ b/tests/framework-fixtures/vite8-react-pricing-cards/files/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { host: '127.0.0.1', strictPort: false }, +}); diff --git a/tests/framework-fixtures/vite8-react-pricing-cards/fixture.json b/tests/framework-fixtures/vite8-react-pricing-cards/fixture.json new file mode 100644 index 000000000..b2bd72fde --- /dev/null +++ b/tests/framework-fixtures/vite8-react-pricing-cards/fixture.json @@ -0,0 +1,41 @@ +{ + "name": "Vite 8 + React + pricing cards below the fold (agent-initiated target)", + "config": { + "files": ["index.html"], + "insertBefore": "", + "commentSyntax": "html" + }, + "sourceFiles": ["index.html", "src/App.jsx", "src/main.jsx", "src/styles.css", "vite.config.js"], + "generatedFiles": [], + "wrapCases": [ + { + "name": "wraps the below-the-fold pricing title in source JSX", + "args": { "classes": "pricing-title", "tag": "h2" }, + "expectedFile": "src/App.jsx" + } + ], + "runtime": { + "styling": "plain-css", + "install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"], + "devCommand": ["npx", "vite", "--host", "127.0.0.1"], + "readyPattern": "Local:\\s+https?://[^:]+:(\\d+)", + "readyTimeoutMs": 120000, + "steer": false, + "pickSelector": "h2.pricing-title", + "acceptedSourcePattern": "]*(class|className)=\"[^\"]*\\bpricing-title\\b", + "assertSourceContains": ["{tier.name}"], + "agentTargetScenario": { + "selector": "h2.pricing-title", + "action": "bolder", + "count": 3, + "prompt": "keep it monochrome", + "minScrollY": 300, + "ambiguousSelector": ".pricing-card", + "missSelector": ".does-not-exist" + }, + "probe": { + "expectLiveInit": true, + "expectConsoleClean": true + } + } +} diff --git a/tests/framework-fixtures/vite8-react-pricing-cards/gitignore.txt b/tests/framework-fixtures/vite8-react-pricing-cards/gitignore.txt new file mode 100644 index 000000000..8cda9ad20 --- /dev/null +++ b/tests/framework-fixtures/vite8-react-pricing-cards/gitignore.txt @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.vite/ +package-lock.json diff --git a/tests/live-agent-target.test.mjs b/tests/live-agent-target.test.mjs new file mode 100644 index 000000000..c2239221a --- /dev/null +++ b/tests/live-agent-target.test.mjs @@ -0,0 +1,799 @@ +/** + * Tests for agent-initiated element targeting (the `generate` command): + * POST /agent-target held-open pairing with POST /agent-target-result, + * validation, the no-browser and timeout verdicts, and the live-generate CLI's + * local failure modes. + * + * Run with: node --test tests/live-agent-target.test.mjs + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { execFile, execFileSync, spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { getLiveServerPath } from '../skill/scripts/lib/impeccable-paths.mjs'; +import { VISUAL_ACTIONS } from '../skill/scripts/live/vocabulary.mjs'; + +// Resolve the repo from this file, not from cwd: the runner may be invoked +// from tests/ or anywhere else. +const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const SERVER_SCRIPT = join(REPO_ROOT, 'skill/scripts/live-server.mjs'); +const GENERATE_SCRIPT = join(REPO_ROOT, 'skill/scripts/live-generate.mjs'); + +function startServer(port, { cwd, env = {} } = {}) { + return new Promise((resolve, reject) => { + const proc = spawn('node', [SERVER_SCRIPT, '--port=' + port], { + cwd, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, IMPECCABLE_LIVE_COPY_AGENT: 'off', ...env }, + }); + let output = ''; + proc.stdout.on('data', (d) => { + output += d.toString(); + if (output.includes('running on')) { + try { + const info = JSON.parse(readFileSync(getLiveServerPath(cwd), 'utf-8')); + resolve({ proc, port: info.port, token: info.token, cwd }); + } catch { + reject(new Error('Server started but PID file not readable')); + } + } + }); + proc.stderr.on('data', (d) => { output += d.toString(); }); + proc.on('error', reject); + setTimeout(() => reject(new Error('Server start timeout. Output: ' + output)), 5000); + }); +} + +async function stopServer(server) { + try { + await fetch(`http://localhost:${server.port}/stop?token=${server.token}`); + } catch { /* already gone */ } +} + +function postJson(server, path, body) { + return fetch(`http://localhost:${server.port}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +/** + * A minimal fake overlay: holds the SSE stream open and resolves pushed + * messages so a test can await the next one matching a predicate. + */ +async function openSseClient(server, { clientId } = {}) { + const controller = new AbortController(); + const res = await fetch( + `http://localhost:${server.port}/events?token=${server.token}` + (clientId ? `&clientId=${clientId}` : ''), + { signal: controller.signal }, + ); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + const messages = []; + const waiters = []; + let buffer = ''; + (async () => { + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + let idx; + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const frame = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const dataLine = frame.split('\n').find((l) => l.startsWith('data: ')); + if (!dataLine) continue; + let msg; + try { msg = JSON.parse(dataLine.slice(6)); } catch { continue; } + messages.push(msg); + for (let i = waiters.length - 1; i >= 0; i -= 1) { + if (waiters[i].match(msg)) { + waiters[i].resolve(msg); + waiters.splice(i, 1); + } + } + } + } + } catch { /* stream closed */ } + })(); + return { + messages, + next(match, timeoutMs = 5000) { + const found = messages.find(match); + if (found) return Promise.resolve(found); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('SSE message timeout')), timeoutMs); + waiters.push({ match, resolve: (m) => { clearTimeout(timer); resolve(m); } }); + }); + }, + close() { controller.abort(); }, + }; +} + +describe('POST /agent-target', () => { + let tmp; + let server; + + before(async () => { + tmp = mkdtempSync(join(tmpdir(), 'impeccable-agent-target-')); + mkdirSync(join(tmp, '.impeccable/live'), { recursive: true }); + writeFileSync(join(tmp, 'index.html'), '

t

'); + // A short timeout keeps the browser_timeout case fast; the env override + // exists exactly for this. + server = await startServer(8497, { + cwd: tmp, + env: { IMPECCABLE_AGENT_TARGET_TIMEOUT_MS: '400' }, + }); + }); + + after(async () => { + await stopServer(server); + rmSync(tmp, { recursive: true, force: true }); + }); + + it('rejects a wrong token with 401', async () => { + const res = await postJson(server, '/agent-target', { + token: 'nope', selector: 'h1', action: 'bolder', count: 3, + }); + assert.equal(res.status, 401); + }); + + it('rejects an invalid action with 400 naming the vocabulary', async () => { + const res = await postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bold', count: 3, + }); + assert.equal(res.status, 400); + const body = await res.json(); + assert.match(body.error, /invalid action/); + assert.match(body.error, /bolder/); + }); + + it('rejects an out-of-range count with 400', async () => { + const res = await postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 9, + }); + assert.equal(res.status, 400); + const body = await res.json(); + assert.match(body.error, /count must be 1-8/); + }); + + it('rejects a missing selector with 400', async () => { + const res = await postJson(server, '/agent-target', { + token: server.token, action: 'bolder', count: 3, + }); + assert.equal(res.status, 400); + const body = await res.json(); + assert.match(body.error, /selector is required/); + }); + + it('answers no_browser_connected when no SSE client is attached', async () => { + const res = await postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.ok, false); + assert.equal(body.error, 'no_browser_connected'); + }); + + it('broadcasts agent_target and resolves the held request with the browser result', async () => { + const sse = await openSseClient(server); + try { + await sse.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, + selector: 'section.pricing', + text: 'Studio', + index: 2, + action: 'bolder', + count: 3, + prompt: 'warmer', + dryRun: true, + }); + const pushed = await sse.next((m) => m.type === 'agent_target'); + assert.equal(pushed.selector, 'section.pricing'); + assert.equal(pushed.text, 'Studio'); + assert.equal(pushed.index, 2); + assert.equal(pushed.action, 'bolder'); + assert.equal(pushed.count, 3); + assert.equal(pushed.prompt, 'warmer'); + assert.equal(pushed.dryRun, true); + assert.match(pushed.targetId, /^[0-9a-f]{8}$/); + + const resultRes = await postJson(server, '/agent-target-result', { + token: server.token, + targetId: pushed.targetId, + ok: true, + matchCount: 1, + sessionId: 'aabbccdd', + element: { tag: 'section', id: null, classes: ['pricing'], text: 'Three ways' }, + }); + assert.deepEqual(await resultRes.json(), { ok: true, delivered: true }); + + const verdict = await (await held).json(); + assert.equal(verdict.ok, true); + assert.equal(verdict.targetId, pushed.targetId); + assert.equal(verdict.matchCount, 1); + assert.equal(verdict.sessionId, 'aabbccdd'); + assert.equal(verdict.element.tag, 'section'); + // The browser's own token must never leak back into the verdict. + assert.equal('token' in verdict, false); + } finally { + sse.close(); + // Give the server's 8s SSE-drop exit timer no chance to fire between + // tests: reconnecting tests open their own client immediately. + } + }); + + it('grants an agent-target claim exactly once, so one visible tab owns the request', async () => { + const sse = await openSseClient(server); + try { + await sse.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await sse.next((m) => m.type === 'agent_target'); + const first = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, + })).json(); + const second = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true, + })).json(); + const renew = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, + })).json(); + assert.deepEqual(first, { ok: true, granted: true, pending: true }); + assert.deepEqual(second, { ok: true, granted: false, pending: true }); + assert.deepEqual(renew, { ok: true, granted: true, pending: true }, 'the holder renews its own lease'); + // Settle the held request so the suite never waits out the timeout. + await postJson(server, '/agent-target-result', { + token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + }); + await (await held).json(); + } finally { + sse.close(); + } + }); + + it('reopens the claim after the winner lease lapses, so a surviving tab can rescue', async () => { + // Own server: the shared one keeps the default 3s claim lease, which its + // 400ms target timeout would delete long before the lease could lapse. + const tmp2 = mkdtempSync(join(tmpdir(), 'impeccable-agent-lease-')); + mkdirSync(join(tmp2, '.impeccable/live'), { recursive: true }); + writeFileSync(join(tmp2, 'index.html'), '

t

'); + const leaseServer = await startServer(8495, { + cwd: tmp2, + env: { IMPECCABLE_AGENT_TARGET_TIMEOUT_MS: '2000', IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS: '250' }, + }); + const sse = await openSseClient(leaseServer); + try { + await sse.next((m) => m.type === 'connected'); + const held = postJson(leaseServer, '/agent-target', { + token: leaseServer.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await sse.next((m) => m.type === 'agent_target'); + const win = await (await postJson(leaseServer, '/agent-target-claim', { + token: leaseServer.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, + })).json(); + const deniedInsideLease = await (await postJson(leaseServer, '/agent-target-claim', { + token: leaseServer.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true, + })).json(); + assert.equal(win.granted, true); + assert.equal(deniedInsideLease.granted, false); + await new Promise((r) => setTimeout(r, 350)); + const rescue = await (await postJson(leaseServer, '/agent-target-claim', { + token: leaseServer.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true, + })).json(); + assert.equal(rescue.granted, true, 'a lapsed lease reopens the claim'); + const staleRenew = await (await postJson(leaseServer, '/agent-target-claim', { + token: leaseServer.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, + })).json(); + assert.equal(staleRenew.granted, false, 'the lapsed holder cannot renew once a rescuer holds the lease'); + await postJson(leaseServer, '/agent-target-result', { + token: leaseServer.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + }); + const verdict = await (await held).json(); + assert.equal(verdict.ok, true); + } finally { + sse.close(); + await stopServer(leaseServer); + rmSync(tmp2, { recursive: true, force: true }); + } + }); + + it('denies a claim for an unknown or already-resolved targetId', async () => { + const res = await postJson(server, '/agent-target-claim', { + token: server.token, targetId: 'deadbeef', clientId: 'tab-x', eligible: true, + }); + assert.deepEqual(await res.json(), { ok: true, granted: false, pending: false }, 'and says the request is gone, which ends a rescue loop'); + }); + + it('answers busy as soon as every connected overlay has reported busy', async () => { + // Roll call: two tabs, both mid-session. Neither claims; each reports + // ineligible, and the second report completes the roll call, so the + // held request answers busy well inside the 400ms target timeout. + const tabA = await openSseClient(server); + const tabB = await openSseClient(server); + try { + await tabA.next((m) => m.type === 'connected'); + await tabB.next((m) => m.type === 'connected'); + const startedAt = Date.now(); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tabA.next((m) => m.type === 'agent_target'); + for (const clientId of ['tab-a', 'tab-b']) { + const report = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId, eligible: false, state: 'CYCLING', reason: 'session_active', + })).json(); + assert.deepEqual(report, { ok: true, granted: false }); + } + const verdict = await (await held).json(); + assert.equal(verdict.error, 'busy'); + assert.equal(verdict.state, 'CYCLING'); + assert.equal(verdict.reason, 'session_active'); + assert.ok(Date.now() - startedAt < 350, 'the busy verdict did not wait for the timeout'); + } finally { + tabA.close(); + tabB.close(); + } + }); + + it('lets an eligible tab serve the request while another tab reports busy', async () => { + const tabA = await openSseClient(server); + const tabB = await openSseClient(server); + try { + await tabA.next((m) => m.type === 'connected'); + await tabB.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tabA.next((m) => m.type === 'agent_target'); + await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: false, state: 'CYCLING', reason: 'session_active', + }); + const claim = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true, + })).json(); + assert.deepEqual(claim, { ok: true, granted: true, pending: true }, 'one busy report does not close a roll call with an idle tab left'); + await postJson(server, '/agent-target-result', { + token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + }); + const verdict = await (await held).json(); + assert.equal(verdict.ok, true); + assert.equal(verdict.sessionId, 'aabbccdd'); + } finally { + tabA.close(); + tabB.close(); + } + }); + + it('completes the roll call when the holder itself turns busy', async () => { + // The holder claimed, then went busy before acting. Its busy report must + // hand the lease back, so the other tab's report completes the roll call + // and the CLI gets busy now, not at the 15s timeout. + const tabA = await openSseClient(server); + const tabB = await openSseClient(server); + try { + await tabA.next((m) => m.type === 'connected'); + await tabB.next((m) => m.type === 'connected'); + const startedAt = Date.now(); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tabA.next((m) => m.type === 'agent_target'); + const claim = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, + })).json(); + assert.deepEqual(claim, { ok: true, granted: true, pending: true }); + await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: false, state: 'GENERATING', reason: 'session_active', + }); + await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: false, state: 'CYCLING', reason: 'session_active', + }); + const verdict = await (await held).json(); + assert.equal(verdict.error, 'busy'); + assert.ok(Date.now() - startedAt < 350, 'the holder handing the lease back let the roll call complete'); + } finally { + tabA.close(); + tabB.close(); + } + }); + + it('withdraws a stale busy report once that tab claims as eligible', async () => { + // Tab A reported busy, then freed up and claimed as eligible before tab B + // reported. B's late busy report must not resolve the request as busy on + // A's stale word; A holds the lease and serves it. + const tabA = await openSseClient(server); + const tabB = await openSseClient(server); + try { + await tabA.next((m) => m.type === 'connected'); + await tabB.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tabA.next((m) => m.type === 'agent_target'); + await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: false, state: 'CYCLING', reason: 'session_active', + }); + const reclaim = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, + })).json(); + assert.deepEqual(reclaim, { ok: true, granted: true, pending: true }, 'the freed tab takes the lease'); + await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: false, state: 'CYCLING', reason: 'session_active', + }); + // Still pending: the stale report was withdrawn, so B's report alone + // does not complete the roll call. A's result resolves it. + const resultRes = await postJson(server, '/agent-target-result', { + token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + }); + assert.deepEqual(await resultRes.json(), { ok: true, delivered: true }); + const verdict = await (await held).json(); + assert.equal(verdict.ok, true); + assert.equal(verdict.sessionId, 'aabbccdd'); + } finally { + tabA.close(); + tabB.close(); + } + }); + + it('tells a denied claimant whether the request is still pending, so rescue retries stop once it is gone', async () => { + // The holder claims and never posts a result. The loser's denied claim + // says the request is still pending, so it keeps retrying; once the + // request times out, the answer says it is gone and the retry loop ends. + const tab = await openSseClient(server); + try { + await tab.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tab.next((m) => m.type === 'agent_target'); + const holder = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, + })).json(); + assert.deepEqual(holder, { ok: true, granted: true, pending: true }); + const denied = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true, + })).json(); + assert.deepEqual(denied, { ok: true, granted: false, pending: true }, 'a live request keeps the loser retrying'); + const verdict = await (await held).json(); + assert.equal(verdict.error, 'browser_timeout'); + const late = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true, + })).json(); + assert.deepEqual(late, { ok: true, granted: false, pending: false }, 'a resolved request ends the retry loop'); + } finally { + tab.close(); + } + }); + + it('replays a pending target to an overlay that connects after the broadcast', async () => { + // Tab A hears the broadcast and stays silent. Tab B connects afterwards + // (a reload mid-request): it must receive the same target, so it can + // claim and serve instead of only widening the roll call's count. + const tabA = await openSseClient(server, { clientId: 'tab-a' }); + let tabB = null; + try { + await tabA.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tabA.next((m) => m.type === 'agent_target'); + tabB = await openSseClient(server, { clientId: 'tab-b' }); + const replayed = await tabB.next((m) => m.type === 'agent_target'); + assert.equal(replayed.targetId, pushed.targetId, 'the late overlay is told about the pending target'); + assert.equal(replayed.selector, 'h1'); + const claim = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true, + })).json(); + assert.deepEqual(claim, { ok: true, granted: true, pending: true }); + await postJson(server, '/agent-target-result', { + token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + }); + const verdict = await (await held).json(); + assert.equal(verdict.ok, true); + assert.equal(verdict.sessionId, 'aabbccdd'); + } finally { + tabA.close(); + if (tabB) tabB.close(); + } + }); + + it('hands a disconnected holder\'s lease back at once', async () => { + // Tab A claims and then disconnects (reload, closed tab). Its lease must + // not have to lapse: tab B's next claim is granted right away. + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const tabA = await openSseClient(server, { clientId: 'tab-a' }); + const tabB = await openSseClient(server, { clientId: 'tab-b' }); + try { + await tabA.next((m) => m.type === 'connected'); + await tabB.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tabB.next((m) => m.type === 'agent_target'); + const holder = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, + })).json(); + assert.equal(holder.granted, true); + tabA.close(); + let claim = { granted: false }; + for (let i = 0; i < 20 && !claim.granted; i += 1) { + await sleep(15); + claim = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true, + })).json(); + } + assert.equal(claim.granted, true, 'the disconnect released the lease well inside the 3s lease and the 400ms timeout'); + await postJson(server, '/agent-target-result', { + token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + }); + const verdict = await (await held).json(); + assert.equal(verdict.ok, true); + } finally { + tabA.close(); + tabB.close(); + } + }); + + it('retires a disconnected overlay\'s busy report instead of answering busy on its stale word', async () => { + // Tab A reports busy and leaves; tab B stays silent. The timeout must + // answer browser_timeout: the only busy word came from a tab that is gone. + const tabA = await openSseClient(server, { clientId: 'tab-a' }); + const tabB = await openSseClient(server, { clientId: 'tab-b' }); + try { + await tabA.next((m) => m.type === 'connected'); + await tabB.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tabB.next((m) => m.type === 'agent_target'); + await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: false, state: 'CYCLING', reason: 'session_active', + }); + tabA.close(); + const verdict = await (await held).json(); + assert.equal(verdict.error, 'browser_timeout'); + } finally { + tabA.close(); + tabB.close(); + } + }); + + it('completes the roll call when the last silent overlay disconnects', async () => { + // Tab A reported busy; tab B never answered and then left. Every overlay + // still connected has declined, so the verdict is busy now, not at the + // timeout. + const tabA = await openSseClient(server, { clientId: 'tab-a' }); + const tabB = await openSseClient(server, { clientId: 'tab-b' }); + try { + await tabA.next((m) => m.type === 'connected'); + await tabB.next((m) => m.type === 'connected'); + const startedAt = Date.now(); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tabA.next((m) => m.type === 'agent_target'); + await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: false, state: 'CYCLING', reason: 'session_active', + }); + tabB.close(); + const verdict = await (await held).json(); + assert.equal(verdict.error, 'busy'); + assert.equal(verdict.reason, 'session_active'); + assert.ok(Date.now() - startedAt < 350, 'the disconnect completed the roll call before the timeout'); + } finally { + tabA.close(); + tabB.close(); + } + }); + + it('carries every action in the live vocabulary from the CLI through the push', async () => { + // The generate command promises the whole action picker, Freeform through + // Overdrive. Drive each value through the CLI and the server, and read it + // back off the broadcast the overlay would act on. + const sse = await openSseClient(server); + try { + await sse.next((m) => m.type === 'connected'); + for (const action of VISUAL_ACTIONS) { + const cli = new Promise((resolve) => { + execFile( + process.execPath, + [GENERATE_SCRIPT, '--selector', 'h1', '--action', action, '--dry-run'], + { cwd: tmp, encoding: 'utf-8' }, + (err, stdout) => resolve({ code: err ? err.code : 0, stdout }), + ); + }); + const pushed = await sse.next( + (m) => m.type === 'agent_target' && m.action === action && m.dryRun === true, + 10_000, + ); + await postJson(server, '/agent-target-result', { + token: server.token, + targetId: pushed.targetId, + ok: true, + dryRun: true, + matchCount: 1, + element: { tag: 'h1', id: null, classes: [], text: 't' }, + }); + const { code, stdout } = await cli; + const verdict = JSON.parse(stdout); + assert.equal(code, 0, `${action}: the CLI exits 0`); + assert.equal(verdict.ok, true, `${action}: the verdict is ok`); + } + } finally { + sse.close(); + } + }); + + it('times out into browser_timeout when the overlay never answers, and a late result reports delivered:false', async () => { + const sse = await openSseClient(server); + try { + await sse.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'quieter', count: 2, + }); + const pushed = await sse.next((m) => m.type === 'agent_target'); + const verdict = await (await held).json(); + assert.equal(verdict.ok, false); + assert.equal(verdict.error, 'browser_timeout'); + assert.equal(verdict.timeoutMs, 400); + + const late = await postJson(server, '/agent-target-result', { + token: server.token, targetId: pushed.targetId, ok: true, + }); + assert.deepEqual(await late.json(), { ok: true, delivered: false }); + } finally { + sse.close(); + } + }); + + it('rejects an agent-target result without a targetId', async () => { + const res = await postJson(server, '/agent-target-result', { + token: server.token, ok: true, + }); + assert.equal(res.status, 400); + const body = await res.json(); + assert.match(body.error, /missing targetId/); + }); +}); + +describe('live-generate CLI --wait-for-browser', () => { + let tmp; + let server; + + before(async () => { + tmp = mkdtempSync(join(tmpdir(), 'impeccable-generate-wait-')); + mkdirSync(join(tmp, '.impeccable/live'), { recursive: true }); + writeFileSync(join(tmp, 'index.html'), '

t

'); + server = await startServer(8496, { + cwd: tmp, + env: { IMPECCABLE_AGENT_TARGET_TIMEOUT_MS: '400' }, + }); + }); + + after(async () => { + await stopServer(server); + rmSync(tmp, { recursive: true, force: true }); + }); + + function runCli(cwd, args) { + try { + const stdout = execFileSync(process.execPath, [GENERATE_SCRIPT, ...args], { + cwd, + encoding: 'utf-8', + }); + return { code: 0, json: JSON.parse(stdout) }; + } catch (err) { + return { code: err.status, json: JSON.parse(err.stdout) }; + } + } + + it('rejects a malformed wait budget locally', () => { + const { code, json } = runCli(tmp, ['--selector', 'h1', '--wait-for-browser', 'soon']); + assert.equal(code, 1); + assert.equal(json.error, 'invalid_wait'); + }); + + it('gives up with no_browser_connected after the wait budget with no page attached', () => { + const startedAt = Date.now(); + const { code, json } = runCli(tmp, ['--selector', 'h1', '--action', 'bolder', '--wait-for-browser', '1500']); + assert.equal(code, 1); + assert.equal(json.error, 'no_browser_connected'); + assert.equal(json.waitedMs, 1500); + assert.ok(Date.now() - startedAt >= 1400, 'the CLI actually waited out the budget'); + }); + + it('proceeds to target as soon as a page connects during the wait', async () => { + // Connect the fake overlay 1.2s into the CLI's wait window. The CLI must + // then send the target; the silent overlay lets it resolve as + // browser_timeout, which proves the wait detected the connection and the + // request went out (no_browser_connected would mean it never did). The + // child runs async: execFileSync would block this process's event loop + // and the delayed connect would never happen. + const child = new Promise((resolve) => { + execFile( + process.execPath, + [GENERATE_SCRIPT, '--selector', 'h1', '--action', 'bolder', '--wait-for-browser', '10000'], + { cwd: tmp, encoding: 'utf-8' }, + (err, stdout) => resolve({ code: err ? err.code : 0, stdout }), + ); + }); + await new Promise((r) => setTimeout(r, 1200)); + const sse = await openSseClient(server); + const res = await child; + sse.close(); + assert.equal(res.code, 1); + const json = JSON.parse(res.stdout); + assert.equal(json.error, 'browser_timeout'); + }); +}); + +describe('live-generate CLI local failure modes', () => { + function runCli(cwd, args) { + try { + const stdout = execFileSync(process.execPath, [GENERATE_SCRIPT, ...args], { + cwd, + encoding: 'utf-8', + }); + return { code: 0, json: JSON.parse(stdout) }; + } catch (err) { + return { code: err.status, json: JSON.parse(err.stdout) }; + } + } + + it('fails with server_not_running when no live server is recorded', () => { + const tmp = mkdtempSync(join(tmpdir(), 'impeccable-generate-cli-')); + try { + const { code, json } = runCli(tmp, ['--selector', 'h1', '--action', 'bolder']); + assert.equal(code, 1); + assert.equal(json.error, 'server_not_running'); + assert.match(json._instructions, /live\.mjs/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('fails with invalid_action locally, listing the vocabulary and the mapping hint', () => { + const tmp = mkdtempSync(join(tmpdir(), 'impeccable-generate-cli-')); + try { + const { code, json } = runCli(tmp, ['--selector', 'h1', '--action', 'bold']); + assert.equal(code, 1); + assert.equal(json.error, 'invalid_action'); + assert.ok(json.validActions.includes('bolder')); + assert.match(json._instructions, /bold -> bolder/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('fails with selector_required when --selector is missing', () => { + const tmp = mkdtempSync(join(tmpdir(), 'impeccable-generate-cli-')); + try { + const { code, json } = runCli(tmp, ['--action', 'bolder']); + assert.equal(code, 1); + assert.equal(json.error, 'selector_required'); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('fails with invalid_count on a non-integer count', () => { + const tmp = mkdtempSync(join(tmpdir(), 'impeccable-generate-cli-')); + try { + const { code, json } = runCli(tmp, ['--selector', 'h1', '--count', 'many']); + assert.equal(code, 1); + assert.equal(json.error, 'invalid_count'); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index 6f4f440c6..66c82868c 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -808,8 +808,8 @@ describe('live-browser source contracts', () => { ); assert.equal( SOURCE.match(/beginNewLiveConfiguration\(\);/g)?.length || 0, - 3, - 'mouse replace, mouse insert, and keyboard configuration must all supersede older recovery timers', + 4, + 'mouse replace, mouse insert, keyboard configuration, and the agent-target entry must all supersede older recovery timers', ); assert.match( SOURCE, @@ -823,6 +823,42 @@ describe('live-browser source contracts', () => { ); }); + it('re-claims busy-declined agent targets only while the overlay can still serve them', () => { + const teardownSource = SOURCE.match(/function teardown\(\) \{[\s\S]*?\n \}/)?.[0] || ''; + const clearAt = teardownSource.indexOf('busyDeclinedTargets.clear();'); + const idleAt = teardownSource.indexOf("setLiveState('IDLE')"); + assert.ok(clearAt >= 0 && idleAt > clearAt, 'teardown must drop declined targets before its IDLE transition, or a dead overlay re-claims a lease'); + assert.match( + SOURCE, + /function hidePendingApplyDock\(\) \{\s*pendingApplyInFlight = false;\s*retryDeclinedAgentTargets\(\);/, + 'finishing a manual apply must withdraw this tab\'s busy report', + ); + assert.match( + SOURCE, + /pendingApplyInFlight = loading === true;\s*if \(!pendingApplyInFlight\) retryDeclinedAgentTargets\(\);/, + 'clearing the apply flag must withdraw this tab\'s busy report', + ); + const helper = SOURCE.match(/function claimAndActOnAgentTarget\(msg\) \{[\s\S]*?\n \}/)?.[0] || ''; + assert.match(helper, /if \(agentTargetOverlayGone\(\)\) return;/, 'a gone overlay must not take a lease it cannot act on'); + assert.match(helper, /if \(!claim\.pending\) return;/, 'the server, not a timer, ends the rescue loop'); + assert.match( + SOURCE, + /\/events\?token=' \+ TOKEN \+ '&clientId=' \+ AGENT_TARGET_CLIENT_ID/, + 'the SSE connection must carry the overlay id, so a disconnect retires its roll-call word', + ); + assert.match(helper, /setTimeout\(\(\) => claimAndActOnAgentTarget\(msg\), AGENT_TARGET_RESCUE_RETRY_MS\);/, 'a denied claim on a live request retries until the lease lapses'); + assert.match( + SOURCE, + /scrollAgentTargetIntoView\(el, \(\) => \{[\s\S]{0,300}?if \(agentTargetOverlayGone\(\)\) return;[\s\S]{0,400}?claimAgentTarget\(msg\.targetId, \{ eligible: true \}\)/, + 'a tab torn down during the scroll settle must not renew its lease', + ); + assert.equal( + (SOURCE.match(/claimAndActOnAgentTarget\(msg\)/g) || []).length, + 4, + 'the first claim and the busy-to-idle re-claim must share the rescue path (definition, two call sites, the retry)', + ); + }); + it('never DOMParser-injects JSX source (#454)', () => { const isJsxStart = SOURCE.indexOf('function isJsxSourceFile('); const isJsxEnd = SOURCE.indexOf('function sourceHasSessionWrapper(', isJsxStart); diff --git a/tests/live-e2e.test.mjs b/tests/live-e2e.test.mjs index f4224c4b7..885552793 100644 --- a/tests/live-e2e.test.mjs +++ b/tests/live-e2e.test.mjs @@ -810,6 +810,130 @@ for (const { name, fixture } of fixtures) { }); } + // ----------------------------------------------------------------- + // Agent-initiated targeting (the `generate` command). The agent names + // the element over the live-generate CLI; the overlay resolves the + // selector, scrolls to it, enters the picked state, and fires Go with + // no user click. Everything downstream (generate event, variants, + // cycling, accept, carbonize) is the standard pipeline, so the second + // half of this test reuses the core helpers unchanged. + // ----------------------------------------------------------------- + if (shouldRunScenario('agent-target') && fixture.runtime.agentTargetScenario) { + it('agent-initiated target scrolls, picks, and generates without a user click', liveE2eTestOptions, async (t) => { + if (!canRunFakeAgentScenario(t)) return; + const scenario = fixture.runtime.agentTargetScenario; + const session = await bootFixtureSession({ + name, + fixture, + browser, + agent: createFakeAgent(), + wrapTarget: wrapTargetFromPickedElement, + atomicDelayMs, + log: (m) => t.diagnostic(m), + }); + const { page, appRoot, teardown } = session; + try { + await waitForHandshake(page); + + // Failure contracts first; none of these may start a session. + const miss = runLiveGenerate(appRoot, { selector: scenario.missSelector, action: scenario.action }); + assert.equal(miss.ok, false); + assert.equal(miss.error, 'no_match'); + + const ambiguous = runLiveGenerate(appRoot, { selector: scenario.ambiguousSelector, action: scenario.action }); + assert.equal(ambiguous.ok, false); + assert.equal(ambiguous.error, 'ambiguous'); + assert.ok(ambiguous.matchCount >= 2, 'ambiguous reports the match count'); + assert.ok( + Array.isArray(ambiguous.candidates) && ambiguous.candidates.length >= 2, + 'ambiguous lists candidate descriptors', + ); + + const dry = runLiveGenerate(appRoot, { + selector: scenario.selector, + action: scenario.action, + 'dry-run': true, + }); + assert.equal(dry.ok, true, `dry-run resolved: ${JSON.stringify(dry)}`); + assert.equal(dry.dryRun, true); + assert.equal(dry.matchCount, 1); + + const noSession = await page.evaluate(() => window.__IMPECCABLE_LIVE_CHROME_CORE__.debugState()); + assert.equal(noSession.currentSessionId, null, 'failed and dry-run targets start no session'); + assert.equal(await page.evaluate(() => Math.round(window.scrollY)), 0, 'page has not scrolled yet'); + + // The happy path: resolve, scroll, pick, Go. + t.diagnostic(`Agent-targeting ${scenario.selector} (${scenario.action} x${scenario.count || 3})`); + const res = runLiveGenerate(appRoot, { + selector: scenario.selector, + action: scenario.action, + count: scenario.count || 3, + ...(scenario.prompt ? { prompt: scenario.prompt } : {}), + }); + assert.equal(res.ok, true, `live-generate succeeded: ${JSON.stringify(res)}`); + assert.match(res.sessionId, /^[0-9a-f]{8}$/, 'a session id came back'); + assert.equal(res.action, scenario.action); + + const scrolled = await page.evaluate(() => Math.round(window.scrollY)); + assert.ok( + scrolled >= (scenario.minScrollY || 100), + `browser scrolled to the target (scrollY=${scrolled})`, + ); + const dbg = await page.evaluate(() => window.__IMPECCABLE_LIVE_CHROME_CORE__.debugState()); + assert.equal(dbg.currentSessionId, res.sessionId, 'overlay session matches the CLI result'); + + if (scenario.prompt) { + // The configure bar rebuild once discarded the preset prompt, so + // pin the regression at the wire: the journaled generate event + // must carry the prompt the CLI was given. + const journalPath = join(appRoot, '.impeccable/live/sessions', `${res.sessionId}.jsonl`); + const journaled = readFileSync(journalPath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l)); + const generateEvent = journaled.find((entry) => entry.type === 'generate')?.event; + assert.equal( + generateEvent?.freeformPrompt, + scenario.prompt, + 'the prompt reached the generate event', + ); + } + + // A second target while the session is mid-flight must refuse. + const busy = runLiveGenerate(appRoot, { selector: scenario.selector, action: scenario.action }); + assert.equal(busy.ok, false); + assert.equal(busy.error, 'busy'); + + await waitForCyclingRobust(page, 3, { agentMode: 'fake', log: (m) => t.diagnostic(m) }); + const sourceFile = await locateSessionFile(appRoot); + assert.ok(sourceFile, 'the variants wrapper landed in a source file'); + + await cycleToVariant(page, 2, 3); + t.diagnostic('Accepting variant 2'); + await clickAccept(page, { expectedVariant: 2 }); + await waitForBarHidden(page); + const final = await waitForSourceClean(sourceFile, 20_000, {}); + assert.match( + final, + new RegExp(fixture.runtime.acceptedSourcePattern), + 'accepted source element survives', + ); + for (const needle of fixture.runtime.assertSourceContains || []) { + assert.ok(final.includes(needle), `source still contains ${JSON.stringify(needle)} after accept`); + } + assert.doesNotMatch(final, /data-impeccable-variants="/, 'variants wrapper removed'); + assert.doesNotMatch(final, /impeccable-carbonize-start/, 'carbonize block rewritten'); + + // The overlay is reusable after accept: a fresh dry-run resolves. + const post = runLiveGenerate(appRoot, { + selector: scenario.selector, + action: scenario.action, + 'dry-run': true, + }); + assert.equal(post.ok, true, 'a new target resolves after accept'); + } finally { + await teardownAndResetBrowser(teardown); + } + }); + } + // ----------------------------------------------------------------- // Failure injection for component previews. // ----------------------------------------------------------------- @@ -1363,6 +1487,29 @@ function canRunFakeAgentScenario(t) { return true; } +/** + * Run the live-generate verb (agent-initiated targeting) against a staged + * fixture and parse its JSON verdict. The CLI exits 1 for every ok:false + * outcome, so the JSON is read from the thrown error's stdout in that case. + */ +function runLiveGenerate(appRoot, flags) { + const args = []; + for (const [key, value] of Object.entries(flags)) { + if (value === true) args.push(`--${key}`); + else if (value !== undefined && value !== null) args.push(`--${key}`, String(value)); + } + let stdout; + try { + stdout = runEngineSync('live-generate', args, { cwd: appRoot }); + } catch (err) { + // The verb exits non-zero on every failure verdict but still prints the + // JSON the scenario asserts on. + stdout = err.stdout || ''; + if (!stdout.trim()) throw err; + } + return JSON.parse(stdout); +} + /** * Boot a fixture straight to CYCLING on a component-preview session and hand * back the handles the scenarios need: the manifest, the route source, and the diff --git a/tests/live-reference.test.mjs b/tests/live-reference.test.mjs index cd72d6c06..28be9e42b 100644 --- a/tests/live-reference.test.mjs +++ b/tests/live-reference.test.mjs @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { compileProviderBlocks } from '../scripts/lib/utils.js'; +import { VISUAL_ACTIONS } from '../skill/scripts/live/vocabulary.mjs'; const ROOT = process.cwd(); @@ -171,4 +172,14 @@ describe('live reference authoring contract', () => { 'real-LLM E2E prompt should not hard-code @scope as the universal CSS contract', ); }); + + it('maps every live action in the generate reference', () => { + // generate.md's Step 1 turns request wording into an action value; a + // value the picker offers but the reference never names is a request + // the agent cannot route. + const generateMd = readFileSync(join(ROOT, 'skill/reference/generate.md'), 'utf-8'); + for (const action of VISUAL_ACTIONS) { + assert.match(generateMd, new RegExp('`' + action + '`'), `generate.md must name \`${action}\``); + } + }); }); diff --git a/tests/skill-behavior/README.md b/tests/skill-behavior/README.md index 5e7456005..7495996bd 100644 --- a/tests/skill-behavior/README.md +++ b/tests/skill-behavior/README.md @@ -322,6 +322,9 @@ results remain the completed measurements. | 17 | existing surface; asks whether critique is required before polish | completes read-only advice distinguishing assessment from implementation and explaining critique is optional; reference coverage is diagnostic | | 18 | existing surface; explicitly requests polish followed by a next-command recommendation | loads `polish.md` rather than substituting workflow advice for the requested work | | 19 | tiny spacing edit with PRODUCT.md + DESIGN.md; Bash denied, a real-loader success control, a denied-launcher planning-only case, and a denied-launcher documentation case (PRODUCT.md + index.html, no DESIGN.md) | edits require successful playbook/craft-floor reads and a pre-edit denial warning; planning stays read-only and skips craft-floor; documentation requires successful document.md and source reads before any DESIGN.md write, with the denial disclosed before the first tool call after the denied launcher | +| 20 | PRODUCT.md + DESIGN.md + `index.html`; prompt is `/impeccable generate 2 bold variants of the hero heading` | loads `reference/generate.md`, and before any `live.md` read (live.md alone is the misroute) | +| 21 | same fixture; prompt is natural language with no command word ("Show me a few quieter versions of the hero heading in the browser so I can pick one.") | infers `reference/generate.md` before any `live.md` read | +| 22 | same fixture; prompt is `Make the hero heading bolder.` | does **not** load `reference/generate.md` (a plain refinement stays out of live); which playbook the refinement lands on is existing routing's business, not this guard's | ## Setup launcher-failure branch (2026-09-06, PR #750) diff --git a/tests/skill-behavior/scenarios.test.mjs b/tests/skill-behavior/scenarios.test.mjs index 7057231f1..c33583611 100644 --- a/tests/skill-behavior/scenarios.test.mjs +++ b/tests/skill-behavior/scenarios.test.mjs @@ -14,6 +14,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; import { prepareWorkspace, @@ -79,6 +80,40 @@ function loadedBeforeImplementationWrite(trace, filename) { return loadIndex >= 0 && (writeIndex < 0 || loadIndex < writeIndex); } +/** + * True when `first` was loaded, and loaded before `second` whenever `second` + * was loaded at all. generate.md hands off to live.md, so a run that reaches + * live.md must have gone through generate.md first; live.md alone is the + * misroute. + */ +function loadedBefore(trace, first, second) { + const indexOf = (filename) => { + const needle = filename.toLowerCase(); + return trace.toolCalls.findIndex(({ name, input }) => { + if (name === 'read') return input?.path?.toLowerCase().includes(needle); + if (name === 'bash') return input?.command?.toLowerCase().includes(needle); + return false; + }); + }; + const firstIndex = indexOf(first); + const secondIndex = indexOf(second); + return firstIndex >= 0 && (secondIndex < 0 || firstIndex < secondIndex); +} + +/** + * A generate scenario that reaches the boot leaves a detached live helper + * behind; stop it (idempotent) before the workspace goes away. + */ +function stopLiveHelper(workspace) { + try { + execFileSync( + process.execPath, + [path.join(workspace, '.claude/skills/impeccable/scripts/live-server.mjs'), 'stop'], + { cwd: workspace, stdio: 'ignore', timeout: 10_000 }, + ); + } catch { /* nothing was running */ } +} + function executedUpdateCommands(trace) { const executableSegments = trace.bashCommands.flatMap((command) => command @@ -782,5 +817,86 @@ for (const modelId of resolveModelList()) { cleanupWorkspace(workspace); } }); + + it('scenario 20: explicit generate request routes to generate.md', async () => { + // "generate N variants of " is the command's whole + // grammar. The route must land on generate.md; bolder.md is the + // direction's own playbook and live.md loads it later, so neither + // counts as the route. + const workspace = prepareWorkspace({ + files: { 'PRODUCT.md': PRODUCT_MD_SAMPLE, 'DESIGN.md': DESIGN_MD_SAMPLE, 'index.html': MINIMAL_LANDING_HTML }, + }); + try { + const { trace, text } = await runTurn({ + workspace, + model, + userPrompt: '/impeccable generate 2 bold variants of the hero heading', + maxSteps: 6, + }); + logTrace('S20', 'generate-explicit', modelId, trace, { textSample: text.slice(0, 400) }); + assert.ok( + loadedBefore(trace, 'generate.md', 'live.md'), + `agent should load generate.md for an explicit generate request, before any live.md read.\n` + + `Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`, + ); + } finally { + stopLiveHelper(workspace); + cleanupWorkspace(workspace); + } + }); + + it('scenario 21: natural-language variant request infers generate', async () => { + // No command word and no "generate": the intent is carried by + // "versions", "in the browser", and "pick one". A model that reads + // that as a source-side bolder or quieter edit misroutes. + const workspace = prepareWorkspace({ + files: { 'PRODUCT.md': PRODUCT_MD_SAMPLE, 'DESIGN.md': DESIGN_MD_SAMPLE, 'index.html': MINIMAL_LANDING_HTML }, + }); + try { + const { trace, text } = await runTurn({ + workspace, + model, + userPrompt: 'Show me a few quieter versions of the hero heading in the browser so I can pick one.', + maxSteps: 6, + }); + logTrace('S21', 'generate-implicit', modelId, trace, { textSample: text.slice(0, 400) }); + assert.ok( + loadedBefore(trace, 'generate.md', 'live.md'), + `agent should infer generate.md from a versions-to-pick-from request, before any live.md read.\n` + + `Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`, + ); + } finally { + stopLiveHelper(workspace); + cleanupWorkspace(workspace); + } + }); + + it('scenario 22: a plain refinement request stays out of generate', async () => { + // The inverse guard: "make it bolder" asks for one edit in source, not + // for variants to choose from in a browser. Over-triggering generate + // here would drag every refinement into a live session. Which playbook + // the refinement itself lands on is the existing sub-command routing's + // business, not this guard's. + const workspace = prepareWorkspace({ + files: { 'PRODUCT.md': PRODUCT_MD_SAMPLE, 'DESIGN.md': DESIGN_MD_SAMPLE, 'index.html': MINIMAL_LANDING_HTML }, + }); + try { + const { trace, text } = await runTurn({ + workspace, + model, + userPrompt: 'Make the hero heading bolder.', + maxSteps: 6, + }); + logTrace('S22', 'refinement-not-generate', modelId, trace, { textSample: text.slice(0, 400) }); + assert.equal( + fileLoaded(trace, 'generate.md'), + false, + `a plain refinement must not route into generate.md.\n` + + `Trace: ${JSON.stringify(summarizeTrace(trace), null, 2)}`, + ); + } finally { + cleanupWorkspace(workspace); + } + }); }); } From d397140a773a62016b99a24fb114e592b0cd2009 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Sat, 5 Sep 2026 08:07:56 +0500 Subject: [PATCH 05/42] Port /impeccable generate to the engine crates The Node-era server, CLI, hook, and pin halves of the generate command move into the Rust workspace, with the protocol unchanged: - crates/live: POST /agent-target is held open on a channel plus a timer thread (the manual-apply deferred pattern), releasing its turnstile ticket before it parks like /poll; /agent-target-result resolves it; /agent-target-claim is the roll call with its renewable lease. SSE connections carry the overlay's clientId: a late overlay is replayed every pending target, and a disconnect retires that overlay's report, releases its lease, and re-judges each roll call. Shutdown drains held requests with server_stopping. - crates/live/src/live_generate.rs: the live-generate verb (the router already forwards every live* verb), same flags, verdicts, and _instructions, spelled with the engine's self command. - crates/hook: every entry stands down on live preview markers (skipped: live-preview), checking the proposed content and the file on disk for hook-before-edit. - crates/context: pin accepts generate; the crate's command-metadata.json copy carries its entry. Tests: crates/cli/tests/agent_target.rs (six HTTP cases with an SSE reader), tests/live-agent-target.test.mjs rewritten to drive the binary (28 cases, registered in the live suite), hook stand-down cases, oracle goldens for live-generate plus the re-recorded pin list goldens, the e2e prompt assertion waiting for the journaled event, and the contract documented in docs/CLI-CONTRACT.md. AI-assisted: implemented and tested with Claude Code under maintainer direction. Co-Authored-By: Claude Fable 5 --- crates/cli/tests/agent_target.rs | 333 +++++++++++++++++ crates/context/src/command-metadata.json | 4 + crates/context/src/pin.rs | 4 +- crates/hook/src/before_edit.rs | 11 + crates/hook/src/hook.rs | 7 + crates/hook/src/hook_lib.rs | 10 + crates/hook/tests/hook_tests.rs | 59 +++ crates/live/src/lib.rs | 2 + crates/live/src/live_generate.rs | 348 ++++++++++++++++++ crates/live/src/live_server.rs | 223 ++++++++++- crates/live/src/server_state.rs | 285 +++++++++++++- docs/CLI-CONTRACT.md | 24 +- scripts/test-suites.mjs | 2 + skill/reference/generate.md | 10 +- skill/scripts/live-generate.mjs | 227 ------------ tests/live-agent-target.test.mjs | 104 ++++-- tests/live-e2e.test.mjs | 9 +- tests/live-reference.test.mjs | 15 +- tests/oracle/cases/live-generate.mjs | 44 +++ .../golden/live-generate-local-verdicts.json | 61 +++ .../live-generate-no-browser-connected.json | 32 ++ .../oracle/golden/pin-bad-command-teach.json | 2 +- tests/oracle/golden/pin-bad-command.json | 2 +- tests/oracle/golden/pin-usage-no-args.json | 2 +- tests/oracle/golden/pin-usage-one-arg.json | 2 +- tests/skill-behavior/scenarios.test.mjs | 13 +- 26 files changed, 1533 insertions(+), 302 deletions(-) create mode 100644 crates/cli/tests/agent_target.rs create mode 100644 crates/live/src/live_generate.rs delete mode 100644 skill/scripts/live-generate.mjs create mode 100644 tests/oracle/cases/live-generate.mjs create mode 100644 tests/oracle/golden/live-generate-local-verdicts.json create mode 100644 tests/oracle/golden/live-generate-no-browser-connected.json diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs new file mode 100644 index 000000000..0248a2ef9 --- /dev/null +++ b/crates/cli/tests/agent_target.rs @@ -0,0 +1,333 @@ +//! Agent-initiated element targeting (the `generate` command) against a real +//! `live-server`: the held-open `POST /agent-target`, the overlay's +//! `/agent-target-result`, and the `/agent-target-claim` roll call with its +//! leases. The full 28-case protocol matrix runs from Node +//! (tests/live-agent-target.test.mjs); this covers the core paths so +//! `cargo test --workspace` gates them on every platform. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::path::Path; +use std::time::{Duration, Instant}; + +fn http(port: u16, method: &str, target: &str, body: Option<&str>) -> (u16, String) { + let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect"); + s.set_read_timeout(Some(Duration::from_secs(20))).unwrap(); + let body = body.unwrap_or(""); + let req = format!( + "{} {} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + method, + target, + port, + body.len(), + body + ); + s.write_all(req.as_bytes()).unwrap(); + let mut out = Vec::new(); + let _ = s.read_to_end(&mut out); + let text = String::from_utf8_lossy(&out).into_owned(); + let status: u16 = text.split_whitespace().nth(1).and_then(|c| c.parse().ok()).unwrap_or(0); + let body = text.split_once("\r\n\r\n").map(|(_, b)| b.to_string()).unwrap_or_default(); + let body = if text.to_ascii_lowercase().contains("transfer-encoding: chunked") { + let mut rest = body.as_str(); + let mut assembled = String::new(); + while let Some((size_line, after)) = rest.split_once("\r\n") { + let size = usize::from_str_radix(size_line.trim(), 16).unwrap_or(0); + if size == 0 { + break; + } + assembled.push_str(&after[..size.min(after.len())]); + rest = after.get(size + 2..).unwrap_or(""); + } + assembled + } else { + body + }; + (status, body) +} + +fn post_json(port: u16, path: &str, body: serde_json::Value) -> (u16, serde_json::Value) { + let (status, text) = http(port, "POST", path, Some(&body.to_string())); + let parsed = serde_json::from_str(&text).unwrap_or(serde_json::json!({ "raw": text })); + (status, parsed) +} + +/// A minimal fake overlay: holds the SSE stream open and yields `data:` frames. +struct Overlay { + reader: BufReader, +} + +impl Overlay { + fn connect(port: u16, token: &str, client_id: &str) -> Overlay { + let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect sse"); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + let req = format!( + "GET /events?token={}&clientId={} HTTP/1.1\r\nHost: 127.0.0.1:{}\r\nAccept: text/event-stream\r\n\r\n", + token, client_id, port + ); + s.write_all(req.as_bytes()).unwrap(); + let mut reader = BufReader::new(s); + // Consume the response head. + loop { + let mut line = String::new(); + let n = reader.read_line(&mut line).expect("sse head"); + if n == 0 || line == "\r\n" { + break; + } + } + Overlay { reader } + } + + /// The next `data:` frame whose parsed JSON satisfies `matches`; chunk + /// size lines and keepalives are skipped. + fn next(&mut self, matches: impl Fn(&serde_json::Value) -> bool) -> serde_json::Value { + let deadline = Instant::now() + Duration::from_secs(10); + while Instant::now() < deadline { + let mut line = String::new(); + match self.reader.read_line(&mut line) { + Ok(0) => panic!("sse stream closed"), + Ok(_) => {} + Err(e) => panic!("sse read: {e}"), + } + let line = line.trim_end_matches(['\r', '\n']); + if let Some(json) = line.strip_prefix("data: ") { + if let Ok(v) = serde_json::from_str::(json) { + if matches(&v) { + return v; + } + } + } + } + panic!("no matching sse frame within 10s"); + } +} + +fn wait_for(p: &Path, secs: u64) -> bool { + let deadline = Instant::now() + Duration::from_secs(secs); + while !p.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(50)); + } + p.exists() +} + +fn free_port() -> u16 { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = l.local_addr().unwrap().port(); + drop(l); + port +} + +struct Server { + child: std::process::Child, + dir: std::path::PathBuf, + port: u16, + token: String, +} + +impl Server { + fn start(tag: &str) -> Server { + let dir = std::env::temp_dir().join(format!("impeccable-agent-target-{}-{}", tag, std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join(".impeccable/live")).unwrap(); + std::fs::write(dir.join("index.html"), "

t

").unwrap(); + let port = free_port(); + let child = std::process::Command::new(env!("CARGO_BIN_EXE_impeccable")) + .args(["live-server", &format!("--port={}", port)]) + .current_dir(&dir) + .env("IMPECCABLE_LIVE_COPY_AGENT", "off") + // A short timeout keeps the browser_timeout case fast; the env + // override exists exactly for this. The lease shrinks with it. + .env("IMPECCABLE_AGENT_TARGET_TIMEOUT_MS", "400") + .env("IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS", "250") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn live-server"); + let pid_file = dir.join(".impeccable/live/server.json"); + assert!(wait_for(&pid_file, 10), "server pid file never appeared"); + let info: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&pid_file).unwrap()).unwrap(); + let port = info["port"].as_u64().expect("port") as u16; + let token = info["token"].as_str().expect("token").to_string(); + Server { child, dir, port, token } + } + + fn target(&self, extra: serde_json::Value) -> serde_json::Value { + let mut body = serde_json::json!({ "token": self.token, "selector": "h1", "action": "bolder", "count": 3 }); + if let (Some(b), Some(e)) = (body.as_object_mut(), extra.as_object()) { + for (k, v) in e { + b.insert(k.clone(), v.clone()); + } + } + body + } + + /// POST /agent-target on a thread: the server holds it until a verdict. + fn hold(&self, extra: serde_json::Value) -> std::thread::JoinHandle<(u16, serde_json::Value)> { + let port = self.port; + let body = self.target(extra); + std::thread::spawn(move || post_json(port, "/agent-target", body)) + } + + fn claim(&self, target_id: &str, client_id: &str, eligible: bool) -> serde_json::Value { + let body = if eligible { + serde_json::json!({ "token": self.token, "targetId": target_id, "clientId": client_id, "eligible": true }) + } else { + serde_json::json!({ "token": self.token, "targetId": target_id, "clientId": client_id, "eligible": false, "state": "CYCLING", "reason": "session_active" }) + }; + post_json(self.port, "/agent-target-claim", body).1 + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = http(self.port, "GET", &format!("/stop?token={}", self.token), None); + let _ = self.child.kill(); + let _ = self.child.wait(); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +#[test] +fn agent_target_validates_and_answers_no_browser() { + let s = Server::start("validate"); + let (st, body) = post_json(s.port, "/agent-target", serde_json::json!({ "token": "nope", "selector": "h1", "action": "bolder", "count": 3 })); + assert_eq!(st, 401, "{body}"); + let (st, body) = post_json(s.port, "/agent-target", s.target(serde_json::json!({ "action": "bold" }))); + assert_eq!(st, 400); + assert!(body["error"].as_str().unwrap().contains("invalid action"), "{body}"); + assert!(body["error"].as_str().unwrap().contains("bolder"), "{body}"); + let (st, body) = post_json(s.port, "/agent-target", s.target(serde_json::json!({ "count": 9 }))); + assert_eq!(st, 400); + assert_eq!(body["error"], serde_json::json!("agent_target: count must be 1-8")); + let (st, body) = post_json(s.port, "/agent-target", serde_json::json!({ "token": s.token, "action": "bolder", "count": 3 })); + assert_eq!(st, 400); + assert_eq!(body["error"], serde_json::json!("agent_target: selector is required")); + // No overlay attached: answered at once, not held. + let (st, body) = post_json(s.port, "/agent-target", s.target(serde_json::json!({}))); + assert_eq!(st, 200); + assert_eq!(body["ok"], serde_json::json!(false)); + assert_eq!(body["error"], serde_json::json!("no_browser_connected")); +} + +#[test] +fn agent_target_broadcasts_and_resolves_with_the_browser_result() { + let s = Server::start("resolve"); + let mut tab = Overlay::connect(s.port, &s.token, "tab-a"); + tab.next(|m| m["type"] == "connected"); + let held = s.hold(serde_json::json!({ "text": "Studio", "index": 2, "prompt": "warmer", "dryRun": true })); + let pushed = tab.next(|m| m["type"] == "agent_target"); + assert_eq!(pushed["selector"], serde_json::json!("h1")); + assert_eq!(pushed["text"], serde_json::json!("Studio")); + assert_eq!(pushed["index"], serde_json::json!(2)); + assert_eq!(pushed["prompt"], serde_json::json!("warmer")); + assert_eq!(pushed["dryRun"], serde_json::json!(true)); + let target_id = pushed["targetId"].as_str().expect("targetId").to_string(); + assert_eq!(target_id.len(), 8); + let claim = s.claim(&target_id, "tab-a", true); + assert_eq!(claim, serde_json::json!({ "ok": true, "granted": true, "pending": true })); + // A second tab is denied while the lease is held, and told the request + // is still pending. + let denied = s.claim(&target_id, "tab-b", true); + assert_eq!(denied, serde_json::json!({ "ok": true, "granted": false, "pending": true })); + let (st, ack) = post_json( + s.port, + "/agent-target-result", + serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "dryRun": true, "matchCount": 1, "element": { "tag": "h1" } }), + ); + assert_eq!(st, 200); + assert_eq!(ack, serde_json::json!({ "ok": true, "delivered": true })); + let (st, verdict) = held.join().unwrap(); + assert_eq!(st, 200, "{verdict}"); + assert_eq!(verdict["targetId"], serde_json::json!(target_id)); + assert_eq!(verdict["ok"], serde_json::json!(true)); + assert_eq!(verdict["matchCount"], serde_json::json!(1)); + // Resolved: a late result reports delivered:false, a late claim says gone. + let (_, late) = post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true })); + assert_eq!(late, serde_json::json!({ "ok": true, "delivered": false })); + assert_eq!(s.claim(&target_id, "tab-b", true), serde_json::json!({ "ok": true, "granted": false, "pending": false })); +} + +#[test] +fn agent_target_times_out_when_the_overlay_never_answers() { + let s = Server::start("timeout"); + let mut tab = Overlay::connect(s.port, &s.token, "tab-a"); + tab.next(|m| m["type"] == "connected"); + let started = Instant::now(); + let held = s.hold(serde_json::json!({})); + tab.next(|m| m["type"] == "agent_target"); + let (st, verdict) = held.join().unwrap(); + assert_eq!(st, 200); + assert_eq!(verdict["error"], serde_json::json!("browser_timeout")); + assert_eq!(verdict["timeoutMs"], serde_json::json!(400)); + assert!(started.elapsed() < Duration::from_secs(5)); +} + +#[test] +fn agent_target_roll_call_answers_busy_once_every_overlay_declined() { + let s = Server::start("busy"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + let mut b = Overlay::connect(s.port, &s.token, "tab-b"); + a.next(|m| m["type"] == "connected"); + b.next(|m| m["type"] == "connected"); + let started = Instant::now(); + let held = s.hold(serde_json::json!({})); + let pushed = a.next(|m| m["type"] == "agent_target"); + let target_id = pushed["targetId"].as_str().unwrap().to_string(); + assert_eq!(s.claim(&target_id, "tab-a", false), serde_json::json!({ "ok": true, "granted": false })); + assert_eq!(s.claim(&target_id, "tab-b", false), serde_json::json!({ "ok": true, "granted": false })); + let (_, verdict) = held.join().unwrap(); + assert_eq!(verdict["error"], serde_json::json!("busy")); + assert_eq!(verdict["state"], serde_json::json!("CYCLING")); + assert_eq!(verdict["reason"], serde_json::json!("session_active")); + assert!(started.elapsed() < Duration::from_millis(350), "the busy verdict did not wait for the timeout"); +} + +#[test] +fn agent_target_lease_lapses_and_a_disconnect_releases_it() { + let s = Server::start("lease"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + let mut b = Overlay::connect(s.port, &s.token, "tab-b"); + a.next(|m| m["type"] == "connected"); + b.next(|m| m["type"] == "connected"); + let held = s.hold(serde_json::json!({})); + let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string(); + // A holds the lease; B is denied inside it. + assert_eq!(s.claim(&target_id, "tab-a", true)["granted"], serde_json::json!(true)); + assert_eq!(s.claim(&target_id, "tab-b", true)["granted"], serde_json::json!(false)); + // A leaves without a result: its lease is handed back at once, well + // inside the 250ms lease, and B rescues the request. + drop(a); + let mut granted = false; + for _ in 0..20 { + std::thread::sleep(Duration::from_millis(15)); + if s.claim(&target_id, "tab-b", true)["granted"] == serde_json::json!(true) { + granted = true; + break; + } + } + assert!(granted, "the disconnect released the lease"); + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccdd" })); + let (_, verdict) = held.join().unwrap(); + assert_eq!(verdict["ok"], serde_json::json!(true)); + assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd")); + let _ = &mut b; +} + +#[test] +fn agent_target_replays_pending_targets_to_a_late_overlay() { + let s = Server::start("replay"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + a.next(|m| m["type"] == "connected"); + let held = s.hold(serde_json::json!({})); + let pushed = a.next(|m| m["type"] == "agent_target"); + let target_id = pushed["targetId"].as_str().unwrap().to_string(); + // B connects after the broadcast and still hears the pending target. + let mut b = Overlay::connect(s.port, &s.token, "tab-b"); + let replayed = b.next(|m| m["type"] == "agent_target"); + assert_eq!(replayed["targetId"], serde_json::json!(target_id)); + assert_eq!(s.claim(&target_id, "tab-b", true)["granted"], serde_json::json!(true)); + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccdd" })); + let (_, verdict) = held.join().unwrap(); + assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd")); +} diff --git a/crates/context/src/command-metadata.json b/crates/context/src/command-metadata.json index dad8ef2e0..89891ea4d 100644 --- a/crates/context/src/command-metadata.json +++ b/crates/context/src/command-metadata.json @@ -19,6 +19,10 @@ "description": "Interactive live variant mode. Select elements in the browser, pick a design action, and get AI-generated HTML+CSS variants hot-swapped via HMR. Requires a running dev server. Use when you want to visually experiment with design alternatives in real time.", "argumentHint": "" }, + "generate": { + "description": "Agent-driven live variant generation. Boots live mode, finds the named element on the open page, scrolls the browser to it, and delivers N variants in the requested direction for the user to cycle and accept. Use for requests that name an element and a direction, like 'generate 3 bold variants of the pricing cards', skipping manual element picking.", + "argumentHint": "[count] [direction] variants of [element]" + }, "adapt": { "description": "Adapt designs to work across different screen sizes, devices, contexts, or platforms. Implements breakpoints, fluid layouts, and touch targets. Use when the user mentions responsive design, mobile layouts, breakpoints, viewport adaptation, or cross-device compatibility.", "argumentHint": "[target] [context (mobile, tablet, print...)]" diff --git a/crates/context/src/pin.rs b/crates/context/src/pin.rs index 16902402b..84cd22da6 100644 --- a/crates/context/src/pin.rs +++ b/crates/context/src/pin.rs @@ -13,10 +13,10 @@ const HARNESS_DIRS: [&str; 18] = [ ".pi", ".opencode", ".kiro", ".rovodev", ".vibe", ".qoder", ]; const CODEX_HARNESSES: [&str; 2] = [".codex", ".agents"]; -pub const VALID_COMMANDS: [&str; 23] = [ +pub const VALID_COMMANDS: [&str; 24] = [ "craft", "init", "extract", "document", "shape", "critique", "audit", "polish", "bolder", "quieter", "distill", "harden", "onboard", "live", "animate", "colorize", "typeset", "layout", "delight", "overdrive", "clarify", - "adapt", "optimize", + "adapt", "optimize", "generate", ]; const PIN_MARKER: &str = ""; const OPENCODE_PIN_MARKER: &str = ""; diff --git a/crates/hook/src/before_edit.rs b/crates/hook/src/before_edit.rs index e021a9e34..30d5ea360 100644 --- a/crates/hook/src/before_edit.rs +++ b/crates/hook/src/before_edit.rs @@ -777,6 +777,17 @@ fn main_flow(rt: &Runtime, stdin: &str) -> Out { if content.len() as u64 > MAX_SCANNED_BYTES { return skip(&audit, "content-too-large"); } + // A live variant session owns files carrying preview scaffolding. Check + // the proposed content AND the file on disk: the very first variants + // write introduces the markers, and later fragment edits (variant CSS + // tweaks) touch a file that already carries them. + if crate::hook_lib::has_live_preview_markers(&content) + || read_existing_project_file(rt, &file_path, &cwd) + .map(|on_disk| crate::hook_lib::has_live_preview_markers(&on_disk)) + .unwrap_or(false) + { + return skip(&audit, "live-preview"); + } if !config.enabled { return skip(&audit, "config-disabled"); } diff --git a/crates/hook/src/hook.rs b/crates/hook/src/hook.rs index bb895e36c..291567cac 100644 --- a/crates/hook/src/hook.rs +++ b/crates/hook/src/hook.rs @@ -276,6 +276,10 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult { }; } }; + if crate::hook_lib::has_live_preview_markers(&content) { + last_skip = "live-preview"; + continue; + } let scan = scans.entry(file_path.clone()).or_insert_with(|| { design_system_options_for_file(rt, &config, &project_cwd, file_path) }); @@ -707,6 +711,9 @@ pub fn run_stop_hook(rt: &Runtime, stdin: &str) -> RunResult { Ok(b) => String::from_utf8_lossy(&b).into_owned(), Err(_) => continue, }; + if crate::hook_lib::has_live_preview_markers(&content) { + continue; + } let use_html_engine = match configured { Some(c) => c.engine == "html", None => ext == ".html" || ext == ".htm", diff --git a/crates/hook/src/hook_lib.rs b/crates/hook/src/hook_lib.rs index 160b6994b..76628c3e4 100644 --- a/crates/hook/src/hook_lib.rs +++ b/crates/hook/src/hook_lib.rs @@ -2463,3 +2463,13 @@ pub fn normalize_rule_id(v: &str) -> String { pub fn js_slice(s: &str, start: usize, end: usize) -> String { slice_utf16(s, start, end) } + +/// A live variant session owns files carrying preview scaffolding: the +/// wrapper a generate publishes and the carbonize block an accept leaves +/// until cleanup. Findings on those files are noise (variants are meant to +/// be tried, not audited) and acting on them derails the session mid-cycle, +/// so every hook entry stands down on the markers; `live-complete` verifies +/// the file once the accepted variant is permanent. +pub fn has_live_preview_markers(content: &str) -> bool { + content.contains("data-impeccable-variants=") || content.contains("impeccable-carbonize-start") +} diff --git a/crates/hook/tests/hook_tests.rs b/crates/hook/tests/hook_tests.rs index 3ffffd376..e8e470478 100644 --- a/crates/hook/tests/hook_tests.rs +++ b/crates/hook/tests/hook_tests.rs @@ -2199,3 +2199,62 @@ fn codex_stop_emits_decision_block() { assert!(out["reason"].as_str().unwrap().contains("[side-tab]")); assert!(out.get("hookSpecificOutput").is_none()); } + +// ── live-preview stand-down ─────────────────────────────────────────────── +// +// A live variant session owns files carrying preview scaffolding +// (`data-impeccable-variants=` wrappers, `impeccable-carbonize-start` +// blocks). Every hook entry stands down on them: findings there are noise +// and acting on them derails the session; live-complete verifies the file +// once the accepted variant is permanent. + +#[test] +fn run_hook_stands_down_on_live_preview_markers() { + let t = Tmp::new(); + let cwd = t.path(); + let r = rt(&cwd); + // Control: the same slop without markers is reported. + let plain = t.write("src/plain.css", GRADIENT_CSS); + let reported = hook::run_hook(&r, &edit_event(&cwd, &plain, "s1")); + assert!(reported.stdout.contains("[gradient-text]"), "{}", reported.stdout); + // A carbonize block in flight: skipped, nothing emitted. + let carbonized = t.write( + "src/carbonized.css", + &format!("/* impeccable-carbonize-start ab12cd34 */\n{GRADIENT_CSS}/* impeccable-carbonize-end ab12cd34 */\n"), + ); + let skipped = hook::run_hook(&r, &edit_event(&cwd, &carbonized, "s1")); + assert_eq!(skipped.stdout, "", "no findings while live markers are in the file"); + assert_eq!(skipped.audit["skipped"], json!("live-preview")); + // A published variants wrapper, same stand-down. + let wrapped = t.write( + "src/wrapped.html", + "
\n", + ); + let skipped = hook::run_hook(&r, &edit_event(&cwd, &wrapped, "s1")); + assert_eq!(skipped.stdout, ""); + assert_eq!(skipped.audit["skipped"], json!("live-preview")); +} + +#[test] +fn before_edit_stands_down_on_live_preview_markers() { + let t = Tmp::new(); + let cwd = t.path(); + t.write("package.json", "{}"); + let r = rt(&cwd); + let slop = ".t { background: linear-gradient(90deg,#f00,#00f); -webkit-background-clip: text; color: transparent; }\n"; + // Control: denied at normal size without markers. + let (out, _) = hbe(&r, &cursor(&cwd, "Write", json!({"file_path": "src/x.css", "content": slop}))); + assert!(out.starts_with("{\"permission\":\"deny\""), "{out}"); + // The very first variants write introduces the markers in the proposed + // content itself. + let proposed = format!("/* impeccable-carbonize-start ab12cd34 */\n{slop}"); + let (out, code) = hbe(&r, &cursor(&cwd, "Write", json!({"file_path": "src/x.css", "content": proposed}))); + assert_eq!(code, 0); + assert_eq!(out, "{\"permission\":\"allow\"}"); + // Later fragment edits touch a file that already carries them on disk: + // the proposed content alone looks like plain slop. + t.write("src/y.css", "/* impeccable-carbonize-start ab12cd34 */\n.v { color: red; }\n"); + let (out, code) = hbe(&r, &cursor(&cwd, "Write", json!({"file_path": "src/y.css", "content": slop}))); + assert_eq!(code, 0); + assert_eq!(out, "{\"permission\":\"allow\"}"); +} diff --git a/crates/live/src/lib.rs b/crates/live/src/lib.rs index 442e49711..0ee5f9407 100644 --- a/crates/live/src/lib.rs +++ b/crates/live/src/lib.rs @@ -43,6 +43,7 @@ pub mod live_boot; pub mod live_commit_manual_edits; pub mod live_complete; pub mod live_discard_manual_edits; +pub mod live_generate; pub mod live_inject; pub mod live_insert; pub mod live_poll; @@ -68,6 +69,7 @@ pub fn run(verb: &str, args: &[String], io: &mut Io) -> i32 { "live-insert" | "insert" => live_insert::run(args, io), "live-accept" | "accept" => live_accept::run(args, io), "live-server" => live_server::run(args, io), + "live-generate" => live_generate::run(args, io), "live-poll" | "poll" => live_poll::run(args, io), "live-commit-manual-edits" | "commit-manual-edits" => { live_commit_manual_edits::run(args, io) diff --git a/crates/live/src/live_generate.rs b/crates/live/src/live_generate.rs new file mode 100644 index 000000000..ac0be1330 --- /dev/null +++ b/crates/live/src/live_generate.rs @@ -0,0 +1,348 @@ +//! `impeccable live-generate`: agent-initiated element targeting for the +//! `generate` command. +//! +//! Asks the live overlay to find an element by CSS selector, scroll to it, +//! enter the picked state, and fire the normal Go pipeline with the given +//! action and count. On success the browser starts a standard generate +//! session; the agent then handles the resulting `generate` event from the +//! poll loop exactly as live.md describes. Requires a running live helper +//! server (`impeccable live` boot) and an open page with the overlay attached. + +use crate::live_resume::self_cmd; +use crate::paths::read_live_server_info; +use crate::roots::enter_live_root; +use crate::util::println; +use crate::vocabulary::VISUAL_ACTIONS; +use impeccable_common::Io; +use serde_json::{json, Map, Value}; +use std::time::{Duration, Instant}; + +const HELP: &str = "Usage: impeccable live-generate --selector [--text ] [--index ] [--action ] [--count ] [--prompt ] [--dry-run] [--wait-for-browser ] + +Flags: + --selector required; resolved with document.querySelectorAll + --text optional; keeps only matches whose textContent contains it + --index optional; 1-based pick among the remaining matches + --action optional; one of the live action vocabulary (default: impeccable) + --count optional; variants to request, 1-8 (default: 3) + --prompt optional; freeform direction, same as typing before Go + --dry-run optional; resolve and report without starting anything + --wait-for-browser optional; poll the helper until a page with the + overlay connects (or the budget runs out) before sending + the target. +"; + +/// Client-side cap just above the server's 15s hold, so a hung helper still +/// fails fast. +const REQUEST_TIMEOUT_MS: u64 = 20_000; + +struct Flags { + values: Map, + dry_run: bool, +} + +fn parse_flags(argv: &[String]) -> Result { + let mut values = Map::new(); + let mut dry_run = false; + let mut i = 0; + while i < argv.len() { + let arg = &argv[i]; + if !arg.starts_with("--") { + i += 1; + continue; + } + let key = &arg[2..]; + if key == "dry-run" { + dry_run = true; + i += 1; + continue; + } + match argv.get(i + 1) { + Some(v) if !v.starts_with("--") => { + values.insert(key.to_string(), json!(v)); + i += 2; + } + _ => { + return Err(json!({ "ok": false, "error": "missing_flag_value", "flag": arg })); + } + } + } + Ok(Flags { values, dry_run }) +} + +fn flag<'a>(flags: &'a Flags, key: &str) -> Option<&'a str> { + flags.values.get(key).and_then(Value::as_str) +} + +/// JS `Number(v)` then `Number.isInteger`: an integer literal only. +fn int_flag(v: &str) -> Option { + let t = v.trim(); + if t.is_empty() { + return None; + } + if let Ok(i) = t.parse::() { + return Some(i); + } + t.parse::() + .ok() + .filter(|f| f.is_finite() && f.fract() == 0.0) + .map(|f| f as i64) +} + +fn print_json(io: &mut Io, v: &Value) { + println(io, &serde_json::to_string_pretty(v).unwrap_or_default()); +} + +fn fail(io: &mut Io, v: Value) -> i32 { + print_json(io, &v); + 1 +} + +/// The follow-up the agent runs after each verdict. Like the poll loop's +/// `_instructions`, regenerated locally from the verdict, never taken from +/// the wire. +fn instructions_for(result: &Map, self_cmd: &str) -> Option { + let s = |k: &str| result.get(k).and_then(Value::as_str).unwrap_or("").to_string(); + let n = |k: &str| result.get(k).and_then(Value::as_i64).unwrap_or(0); + if result.get("ok").and_then(Value::as_bool) == Some(true) { + if result.get("dryRun").and_then(Value::as_bool) == Some(true) { + let el = result.get("element").and_then(Value::as_object); + let tag = el.and_then(|e| e.get("tag")).and_then(Value::as_str).unwrap_or(""); + let id = el + .and_then(|e| e.get("id")) + .and_then(Value::as_str) + .filter(|i| !i.is_empty()) + .map(|i| format!("#{}", i)) + .unwrap_or_default(); + return Some(format!( + "Dry run only: the selector resolves to one element ({}{}) and no session was started. Rerun without --dry-run to generate.", + tag, id + )); + } + return Some(format!( + "Session {} started: the browser scrolled to the target and fired Go (action \"{}\", count {}). Poll now with {} live-poll; the next event for this session is its generate event. Handle it exactly per live.md's Handle generate, then reply done and keep polling.", + s("sessionId"), s("action"), n("count"), self_cmd + )); + } + let text = match s("error").as_str() { + "no_browser_connected" => "No page with the live overlay is connected. Open the app URL that serves a pageFiles entry yourself with your harness browser tool, then rerun this command. Only when no browser tool exists: give the user the URL and rerun with --wait-for-browser 120000 so the command fires as soon as they open the page.".to_string(), + "browser_timeout" => format!("The overlay did not answer in time. The page may be mid-reload: run {} live-status to check whether a session started anyway, reload the app page, then rerun this command.", self_cmd), + "invalid_selector" => "The selector is not valid CSS. Fix the selector syntax and rerun.".to_string(), + "no_match" => { + if n("rawMatchCount") > 0 { + format!("The selector hit {} node(s) but none is pickable (too small, chrome, or filtered by --text). Target a larger element or adjust --text.", n("rawMatchCount")) + } else { + "The selector matched nothing on the open page. Derive a better selector from the page source (an id, a unique class, or a landmark), or add --text with a snippet of the element's visible text.".to_string() + } + } + "ambiguous" => format!("The selector matched {} elements. Either target their common container instead, or disambiguate with --text \"\" or --index <1-based position>. The candidates are listed in this output.", n("matchCount")), + "index_out_of_range" => format!("--index is out of range: only {} match(es). Use an index from 1 to {}.", n("matchCount"), n("matchCount")), + "busy" => format!("A live session is already mid-flight (browser state {}). Let the user finish or discard it in the browser, or handle the pending event in your poll loop, then rerun.", s("state")), + "go_failed" => format!("The overlay could not start generation from the picked state (browser state {}). Reload the app page and rerun this command.", s("state")), + "server_stopping" => format!("The live helper server is shutting down. Re-run the live boot ({} live), reopen the page, then rerun this command.", self_cmd), + _ => return None, + }; + Some(text) +} + +fn server_died(self_cmd: &str, detail: Option, waiting: bool) -> Value { + let mut v = Map::new(); + v.insert("ok".into(), json!(false)); + v.insert("error".into(), json!("server_unreachable")); + if let Some(d) = detail { + v.insert("detail".into(), json!(d)); + } + let text = if waiting { + format!("The recorded live server did not answer while waiting for a browser; it likely died. Re-run the live boot ({} live), reopen the app page, then rerun this command.", self_cmd) + } else { + format!("The recorded live server did not answer; it likely died. Re-run the live boot ({} live), reopen the app page, then rerun this command.", self_cmd) + }; + v.insert("_instructions".into(), json!(text)); + Value::Object(v) +} + +fn server_not_running(self_cmd: &str) -> Value { + json!({ + "ok": false, + "error": "server_not_running", + "_instructions": format!("No live helper server is recorded for this project. Run the live boot first ({} live), open the app URL that serves a pageFiles entry, then rerun this command.", self_cmd), + }) +} + +pub fn run(args: &[String], io: &mut Io) -> i32 { + let mut argv: Vec = args.to_vec(); + if let Err(code) = enter_live_root(&mut argv, io) { + return code; + } + let cwd = io.cwd.to_string_lossy().into_owned(); + let env = io.env.clone(); + if argv.iter().any(|a| a == "--help" || a == "-h") { + println(io, HELP); + return 0; + } + let me = self_cmd(io); + let flags = match parse_flags(&argv) { + Ok(f) => f, + Err(v) => return fail(io, v), + }; + + let selector = flag(&flags, "selector").map(str::trim).unwrap_or("").to_string(); + if selector.is_empty() { + return fail(io, json!({ + "ok": false, + "error": "selector_required", + "_instructions": "Pass --selector with a CSS selector for the element to target. Derive it from the page source: prefer an id, a unique class, or a landmark section, and add --text \"\" when the class repeats.", + })); + } + let action = flag(&flags, "action").unwrap_or("impeccable").to_string(); + if !VISUAL_ACTIONS.contains(&action.as_str()) { + return fail(io, json!({ + "ok": false, + "error": "invalid_action", + "action": action, + "validActions": VISUAL_ACTIONS, + "_instructions": "Map the request wording onto the closest listed action (bold -> bolder, quiet/calmer -> quieter, simplify -> distill). When no action fits, use --action impeccable and carry the wording via --prompt.", + })); + } + let count = match flag(&flags, "count") { + None => 3, + Some(raw) => match int_flag(raw) { + Some(c) if (1..=8).contains(&c) => c, + _ => { + return fail(io, json!({ "ok": false, "error": "invalid_count", "count": raw, "_instructions": "Pass --count as an integer from 1 to 8." })); + } + }, + }; + let index = match flag(&flags, "index") { + None => None, + Some(raw) => match int_flag(raw) { + Some(i) if i >= 1 => Some(i), + _ => { + return fail(io, json!({ "ok": false, "error": "invalid_index", "index": raw, "_instructions": "Pass --index as a 1-based integer position among the matches." })); + } + }, + }; + let wait_for_browser_ms = match flag(&flags, "wait-for-browser") { + None => 0, + Some(raw) => match int_flag(raw) { + Some(ms) if ms >= 1 => ms as u64, + _ => { + return fail(io, json!({ "ok": false, "error": "invalid_wait", "wait": raw, "_instructions": "Pass --wait-for-browser as a positive integer of milliseconds, e.g. --wait-for-browser 120000." })); + } + }, + }; + + let Some((info, _)) = read_live_server_info(&cwd, &env) else { + return fail(io, server_not_running(&me)); + }; + let port = info.raw.get("port").and_then(Value::as_i64); + let token = info.raw.get("token").and_then(Value::as_str).map(str::to_string); + let (Some(port), Some(token)) = (port, token) else { + return fail(io, server_not_running(&me)); + }; + + if wait_for_browser_ms > 0 { + let deadline = Instant::now() + Duration::from_millis(wait_for_browser_ms); + loop { + let Some(status) = crate::server::fetch_status(port, &token) else { + return fail(io, server_died(&me, None, true)); + }; + if status.get("connectedClients").and_then(Value::as_i64).unwrap_or(0) > 0 { + break; + } + if Instant::now() >= deadline { + let mut v = Map::new(); + v.insert("ok".into(), json!(false)); + v.insert("error".into(), json!("no_browser_connected")); + v.insert("waitedMs".into(), json!(wait_for_browser_ms)); + let text = instructions_for(&v, &me).unwrap_or_default(); + v.insert("_instructions".into(), json!(text)); + return fail(io, Value::Object(v)); + } + std::thread::sleep(Duration::from_millis(1_000)); + } + } + + let mut body = Map::new(); + body.insert("token".into(), json!(token)); + body.insert("selector".into(), json!(selector)); + body.insert("action".into(), json!(action)); + body.insert("count".into(), json!(count)); + if let Some(text) = flag(&flags, "text").filter(|t| !t.is_empty()) { + body.insert("text".into(), json!(text)); + } + if let Some(i) = index { + body.insert("index".into(), json!(i)); + } + if let Some(prompt) = flag(&flags, "prompt").filter(|p| !p.is_empty()) { + body.insert("prompt".into(), json!(prompt)); + } + if flags.dry_run { + body.insert("dryRun".into(), json!(true)); + } + + let url = format!("http://127.0.0.1:{}/agent-target", port); + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_millis(REQUEST_TIMEOUT_MS)) + .build(); + let sent = agent + .post(&url) + .set("Content-Type", "application/json") + .send_string(&serde_json::to_string(&Value::Object(body)).unwrap_or_default()); + let (status, result) = match sent { + Ok(res) => { + let status = res.status(); + match res.into_json::() { + Ok(v) => (status, v), + Err(_) => return fail(io, json!({ "ok": false, "error": "bad_server_response", "status": status })), + } + } + Err(ureq::Error::Status(status, res)) => match res.into_json::() { + Ok(v) => (status, v), + Err(_) => return fail(io, json!({ "ok": false, "error": "bad_server_response", "status": status })), + }, + Err(ureq::Error::Transport(t)) => { + let detail = t.to_string(); + let lower = detail.to_ascii_lowercase(); + if lower.contains("timed out") || lower.contains("timeout") { + let mut v = Map::new(); + v.insert("ok".into(), json!(false)); + v.insert("error".into(), json!("request_timeout")); + v.insert("detail".into(), json!(detail)); + let mut probe = Map::new(); + probe.insert("error".into(), json!("browser_timeout")); + let text = instructions_for(&probe, &me).unwrap_or_default(); + v.insert("_instructions".into(), json!(text)); + return fail(io, Value::Object(v)); + } + return fail(io, server_died(&me, Some(detail), false)); + } + }; + let mut fields = result.as_object().cloned().unwrap_or_default(); + if !(200..300).contains(&status) { + let mut v = Map::new(); + v.insert("ok".into(), json!(false)); + let code = fields + .get("error") + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| format!("http_{}", status)); + v.insert("error".into(), json!(code)); + for (k, val) in fields { + if k != "ok" && k != "error" { + v.insert(k, val); + } + } + return fail(io, Value::Object(v)); + } + let ok = fields.get("ok").and_then(Value::as_bool) == Some(true); + if let Some(text) = instructions_for(&fields, &me) { + fields.insert("_instructions".into(), json!(text)); + } + print_json(io, &Value::Object(fields)); + if ok { + 0 + } else { + 1 + } +} diff --git a/crates/live/src/live_server.rs b/crates/live/src/live_server.rs index 38539c1d1..6b2837ea8 100644 --- a/crates/live/src/live_server.rs +++ b/crates/live/src/live_server.rs @@ -182,6 +182,8 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { next_poll_id: 1, next_client_id: 1, next_apply_timer_gen: 0, + pending_agent_targets: Vec::new(), + next_agent_target_timer_gen: 0, shutting_down: false, cleaned_up: false, log_tx, @@ -518,6 +520,9 @@ fn shutdown(shared: &Shared) { for poll in st.pending_polls.drain(..) { let _ = poll.tx.send(json!({ "type": "exit" })); } + for (_, pending) in st.pending_agent_targets.drain(..) { + let _ = pending.tx.send(json!({ "ok": false, "error": "server_stopping" })); + } // Give response writers a moment to flush before the process exits. drop(st); std::thread::sleep(Duration::from_millis(50)); @@ -907,7 +912,14 @@ fn handle_connection(shared: Shared, mut stream: TcpStream, mut ticket: Ticket) text_res(200, Some("text/html; charset=utf-8"), &content), ); } - ("/events", "GET") => handle_sse(&shared, stream, &cors, token_ok, &mut ticket), + ("/events", "GET") => handle_sse( + &shared, + stream, + &cors, + token_ok, + req.query_get("clientId").map(|s| s.to_string()), + &mut ticket, + ), ("/manual-edit-stash", "POST") | ("/manual-edit-stash", "GET") | ("/manual-edit-commit", "POST") @@ -943,6 +955,16 @@ fn handle_connection(shared: Shared, mut stream: TcpStream, mut ticket: Ticket) ("/poll", "POST") => { handle_poll_post(&shared, &mut stream, &cors, &req, &token_now, &mut ticket) } + // --- Agent-initiated targeting (the `generate` command) --- + ("/agent-target", "POST") => { + handle_agent_target_post(&shared, stream, &cors, &req, &token_now, &mut ticket) + } + ("/agent-target-result", "POST") => { + handle_agent_target_result_post(&shared, &mut stream, &cors, &req, &token_now) + } + ("/agent-target-claim", "POST") => { + handle_agent_target_claim_post(&shared, &mut stream, &cors, &req, &token_now) + } _ => respond(&mut stream, &cors, text_res(404, None, "Not found")), } } @@ -1028,6 +1050,7 @@ fn handle_sse( stream: TcpStream, cors: &[(String, String)], token_ok: bool, + agent_client_id: Option, ticket: &mut Ticket, ) { let mut stream = stream; @@ -1050,7 +1073,7 @@ fn handle_sse( })) .unwrap_or_default() ); - let (id, rx, tx) = st.add_sse_client(); + let (id, rx, tx) = st.add_sse_client(agent_client_id); (id, rx, tx, frame) }; // Registered; the stream now parks, so let later requests through. @@ -2588,6 +2611,196 @@ fn handle_manual_edit_commit( } } +// --------------------------------------------------------------------------- +// Agent-initiated element targeting (the `generate` command) +// --------------------------------------------------------------------------- + +/// JS: validateAgentTargetRequest(msg) +fn validate_agent_target_request(msg: &Value) -> Option { + let selector_ok = matches!(msg.get("selector"), Some(Value::String(s)) if !s.trim().is_empty()); + if !selector_ok { + return Some("agent_target: selector is required".into()); + } + if msg.get("selector").and_then(Value::as_str).map(|s| s.chars().count()).unwrap_or(0) > 1000 { + return Some("agent_target: selector too long".into()); + } + let action_ok = matches!(msg.get("action"), Some(Value::String(a)) if crate::vocabulary::VISUAL_ACTIONS.contains(&a.as_str())); + if !action_ok { + return Some(format!( + "agent_target: invalid action (valid: {})", + crate::vocabulary::VISUAL_ACTIONS.join(", ") + )); + } + let count_ok = match msg.get("count") { + Some(Value::Number(n)) => n.as_i64().map(|c| (1..=8).contains(&c)).unwrap_or(false), + _ => false, + }; + if !count_ok { + return Some("agent_target: count must be 1-8".into()); + } + if let Some(text) = msg.get("text") { + if !matches!(text, Value::String(t) if t.chars().count() <= 500) { + return Some("agent_target: text must be a string of at most 500 chars".into()); + } + } + if let Some(index) = msg.get("index") { + if !index.as_i64().map(|i| i >= 1).unwrap_or(false) { + return Some("agent_target: index must be a positive integer (1-based)".into()); + } + } + if let Some(prompt) = msg.get("prompt") { + if !matches!(prompt, Value::String(p) if p.chars().count() <= 2000) { + return Some("agent_target: prompt must be a string of at most 2000 chars".into()); + } + } + if let Some(dry) = msg.get("dryRun") { + if !dry.is_boolean() { + return Some("agent_target: dryRun must be a boolean".into()); + } + } + None +} + +/// Parse the body and check its token; answers the request itself on failure. +fn agent_target_body( + stream: &mut TcpStream, + cors: &[(String, String)], + req: &Request, + token: &str, +) -> Option> { + let Some(msg) = parse_json_body(req) else { + respond(stream, cors, json_res(400, json!({ "error": "Invalid JSON" }))); + return None; + }; + let obj = msg.as_object().cloned().unwrap_or_default(); + if obj.get("token").and_then(Value::as_str) != Some(token) { + respond(stream, cors, json_res(401, json!({ "error": "Unauthorized" }))); + return None; + } + Some(obj) +} + +/// JS: handleAgentTargetPost: hold the response until the overlay answers. +fn handle_agent_target_post( + shared: &Shared, + stream: TcpStream, + cors: &[(String, String)], + req: &Request, + token: &str, + ticket: &mut Ticket, +) { + let mut stream = stream; + let Some(msg) = agent_target_body(&mut stream, cors, req, token) else { + return; + }; + if let Some(error) = validate_agent_target_request(&Value::Object(msg.clone())) { + respond(&mut stream, cors, json_res(400, json!({ "error": error }))); + return; + } + let mut st = lock(shared); + if st.sse_clients.is_empty() { + drop(st); + respond( + &mut stream, + cors, + json_res(200, json!({ "ok": false, "error": "no_browser_connected" })), + ); + return; + } + let mut payload = Map::new(); + payload.insert("selector".into(), msg.get("selector").cloned().unwrap_or(Value::Null)); + if let Some(text) = msg.get("text").and_then(Value::as_str).filter(|t| !t.is_empty()) { + payload.insert("text".into(), json!(text)); + } + if let Some(index) = msg.get("index").and_then(Value::as_i64) { + payload.insert("index".into(), json!(index)); + } + payload.insert("action".into(), msg.get("action").cloned().unwrap_or(Value::Null)); + payload.insert("count".into(), msg.get("count").cloned().unwrap_or(Value::Null)); + if let Some(prompt) = msg.get("prompt").and_then(Value::as_str).filter(|p| !p.is_empty()) { + payload.insert("prompt".into(), json!(prompt)); + } + if msg.get("dryRun").and_then(Value::as_bool) == Some(true) { + payload.insert("dryRun".into(), json!(true)); + } + let (target_id, rx) = st.register_agent_target(payload); + drop(st); + // Registered and broadcast; the response now parks, so let the claims + // and the result through. + ticket.release(); + let result = rx + .recv() + .unwrap_or_else(|_| json!({ "ok": false, "error": "server_stopping" })); + let mut out = Map::new(); + out.insert("targetId".into(), json!(target_id)); + if let Value::Object(fields) = result { + for (k, v) in fields { + out.insert(k, v); + } + } + respond(&mut stream, cors, json_res(200, Value::Object(out))); +} + +/// JS: handleAgentTargetResultPost +fn handle_agent_target_result_post( + shared: &Shared, + stream: &mut TcpStream, + cors: &[(String, String)], + req: &Request, + token: &str, +) { + let Some(msg) = agent_target_body(stream, cors, req, token) else { + return; + }; + let target_id = match msg.get("targetId") { + Some(Value::String(id)) if !id.is_empty() => id.clone(), + _ => { + respond( + stream, + cors, + json_res(400, json!({ "error": "agent_target_result: missing targetId" })), + ); + return; + } + }; + let mut result = Map::new(); + for (k, v) in msg { + if k != "token" && k != "targetId" { + result.insert(k, v); + } + } + let delivered = lock(shared).resolve_agent_target(&target_id, Value::Object(result)); + respond(stream, cors, json_res(200, json!({ "ok": true, "delivered": delivered }))); +} + +/// JS: handleAgentTargetClaimPost +fn handle_agent_target_claim_post( + shared: &Shared, + stream: &mut TcpStream, + cors: &[(String, String)], + req: &Request, + token: &str, +) { + let Some(msg) = agent_target_body(stream, cors, req, token) else { + return; + }; + let target_id = msg.get("targetId").and_then(Value::as_str).unwrap_or("").to_string(); + let client_id = msg.get("clientId").and_then(Value::as_str).unwrap_or("").to_string(); + if target_id.is_empty() || client_id.is_empty() { + respond( + stream, + cors, + json_res(400, json!({ "error": "agent_target_claim: missing targetId or clientId" })), + ); + return; + } + let eligible = msg.get("eligible").and_then(Value::as_bool) == Some(true); + let state = msg.get("state").cloned().unwrap_or(Value::Null); + let reason = msg.get("reason").cloned().unwrap_or(Value::Null); + let body = lock(shared).claim_agent_target(&target_id, &client_id, eligible, state, reason); + respond(stream, cors, json_res(200, body)); +} + #[cfg(test)] mod content_type_tests { use super::*; @@ -2633,6 +2846,12 @@ mod content_type_tests { assert!(!releases_ticket_up_front("/events", "OPTIONS")); assert!(!releases_ticket_up_front("/poll", "POST")); assert!(!releases_ticket_up_front("/stop", "GET")); + // The agent-target routes mutate the roll call and must keep arrival + // order too: a claim answered before the target it claims registers + // would deny a tab that should have been granted. + assert!(!releases_ticket_up_front("/agent-target", "POST")); + assert!(!releases_ticket_up_front("/agent-target-result", "POST")); + assert!(!releases_ticket_up_front("/agent-target-claim", "POST")); } #[test] diff --git a/crates/live/src/server_state.rs b/crates/live/src/server_state.rs index 778cf14e1..c742ce5ef 100644 --- a/crates/live/src/server_state.rs +++ b/crates/live/src/server_state.rs @@ -39,6 +39,29 @@ pub struct ParkedPoll { pub struct SseClient { pub id: u64, pub tx: Sender, + /// The overlay's per-page-load id (`/events?clientId=`), so a disconnect + /// can retire its word in any agent-target roll call it took part in. + pub agent_client_id: Option, +} + +/// One overlay's roll-call report on an agent target: its busy state and why. +pub struct AgentTargetReport { + pub client_id: String, + pub state: Value, + pub reason: Value, +} + +/// A held-open `POST /agent-target` (the `generate` command): resolved by +/// `POST /agent-target-result`, by a complete busy roll call, by its timeout, +/// or by shutdown. The claim lease decides which overlay acts. +pub struct AgentTargetPending { + pub tx: Sender, + /// The `agent_target` SSE payload, replayed to overlays that connect late. + pub payload: Value, + pub owner: Option, + pub claimed_until: i64, + pub reports: Vec, + pub timer_gen: u64, } /// One pre-apply file snapshot entry (`{ exists, content }`). @@ -84,6 +107,9 @@ pub struct ServerState { pub manual_edit_activity: Option, pub next_manual_edit_seq: i64, pub pending_apply_deferreds: Vec<(String, ApplyDeferred)>, + /// Held-open agent targets keyed by targetId, in arrival order. + pub pending_agent_targets: Vec<(String, AgentTargetPending)>, + pub next_agent_target_timer_gen: u64, pub last_poll_at: i64, pub timed_out_apply_ids: Vec<(String, TimedOutApply)>, pub next_poll_id: u64, @@ -603,26 +629,252 @@ impl ServerState { before != self.pending_polls.len() } - /// Register an SSE client; returns (id, receiver). - pub fn add_sse_client(&mut self) -> (u64, Receiver, Sender) { + /// Register an SSE client; returns (id, receiver). An overlay that + /// connects after an agent target was broadcast (a reload mid-request is + /// the common case) joins its roll call: every pending target is replayed + /// to it, so it claims or declines like the others instead of silently + /// widening the count the roll call is judged against. + pub fn add_sse_client( + &mut self, + agent_client_id: Option, + ) -> (u64, Receiver, Sender) { let (tx, rx) = channel(); let id = self.next_client_id; self.next_client_id += 1; - self.sse_clients.push(SseClient { id, tx: tx.clone() }); + for (_, pending) in &self.pending_agent_targets { + let _ = tx.send(format!( + "data: {}\n\n", + serde_json::to_string(&pending.payload).unwrap_or_else(|_| "null".into()) + )); + } + self.sse_clients.push(SseClient { + id, + tx: tx.clone(), + agent_client_id, + }); (id, rx, tx) } /// Remove an SSE client; when none remain arm the exit timer (JS - /// `req.on('close')`). + /// `req.on('close')`). A departed overlay's word no longer counts in any + /// agent-target roll call. pub fn remove_sse_client(&mut self, id: u64) { let before = self.sse_clients.len(); + let agent_client_id = self + .sse_clients + .iter() + .find(|c| c.id == id) + .and_then(|c| c.agent_client_id.clone()); self.sse_clients.retain(|c| c.id != id); - if before != self.sse_clients.len() && self.sse_clients.is_empty() { - self.clear_exit_timer(); - self.arm_exit_timer(); + if before != self.sse_clients.len() { + self.drop_agent_target_client(agent_client_id.as_deref()); + if self.sse_clients.is_empty() { + self.clear_exit_timer(); + self.arm_exit_timer(); + } } } + // --------------------------------------------------------------------- + // Agent-initiated element targeting (the `generate` command) + // --------------------------------------------------------------------- + // + // POST /agent-target lets the AGENT start a variant session: the server + // pushes an `agent_target` SSE message, the overlay resolves the selector, + // scrolls to the element, enters the same picked state a user click + // produces, and fires the normal Go pipeline. The HTTP response is held + // open until the overlay POSTs /agent-target-result (or the timeout + // fires), so the CLI gets a synchronous verdict. No session exists until + // the browser's own generate event creates one. + + /// Browser must answer an agent_target push within this window. The env + /// override exists for tests; real sessions keep the default. + pub fn agent_target_timeout_ms(&self) -> u64 { + env_positive_ms(&self.env, "IMPECCABLE_AGENT_TARGET_TIMEOUT_MS").unwrap_or(15_000) + } + + /// A granted claim is a lease, not a lock: if the winning tab dies before + /// posting its result (reload, crash), the lease lapses and a surviving + /// tab's retry rescues the request instead of letting it wait out the + /// browser timeout. The lease comfortably exceeds a healthy winner's + /// worst case (claim RTT + smooth-scroll settle + Go, under 2s). + pub fn agent_target_lease_ms(&self) -> i64 { + env_positive_ms(&self.env, "IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS") + .map(|v| v as i64) + .unwrap_or(3_000) + } + + /// Hold a new agent target: mint its id, broadcast the push, arm the + /// timeout. Returns the id and the receiver the route blocks on. + pub fn register_agent_target(&mut self, mut payload: Map) -> (String, Receiver) { + let target_id = crate::random::random_id8(); + payload.insert("targetId".into(), json!(target_id)); + // JS spread order: type, targetId, then the request fields. + let mut ordered = Map::new(); + ordered.insert("type".into(), json!("agent_target")); + ordered.insert("targetId".into(), json!(target_id)); + for (k, v) in payload { + if k != "type" && k != "targetId" { + ordered.insert(k, v); + } + } + let payload = Value::Object(ordered); + let (tx, rx) = channel(); + self.next_agent_target_timer_gen += 1; + let timer_gen = self.next_agent_target_timer_gen; + self.pending_agent_targets.push(( + target_id.clone(), + AgentTargetPending { + tx, + payload: payload.clone(), + owner: None, + claimed_until: 0, + reports: Vec::new(), + timer_gen, + }, + )); + self.broadcast(&payload); + let timeout_ms = self.agent_target_timeout_ms(); + let weak = self.self_ref.clone(); + let id = target_id.clone(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(timeout_ms)); + if let Some(shared) = weak.upgrade() { + let mut st = lock(&shared); + let Some((_, pending)) = st + .pending_agent_targets + .iter() + .find(|(k, p)| *k == id && p.timer_gen == timer_gen) + else { + return; + }; + let verdict = if pending.reports.is_empty() { + json!({ "ok": false, "error": "browser_timeout", "timeoutMs": timeout_ms }) + } else { + agent_target_busy_verdict(pending) + }; + st.resolve_agent_target(&id, verdict); + } + }); + (target_id, rx) + } + + /// Deliver a verdict to the held request; false when nothing awaits it. + pub fn resolve_agent_target(&mut self, target_id: &str, result: Value) -> bool { + let Some(pos) = self + .pending_agent_targets + .iter() + .position(|(k, _)| k == target_id) + else { + return false; + }; + let (_, pending) = self.pending_agent_targets.remove(pos); + let _ = pending.tx.send(result); + true + } + + /// Every connected overlay has declined: answer busy now, not at the + /// timeout. Judged against the connections of this moment, so it runs + /// whenever a report lands and whenever an overlay leaves. + pub fn maybe_complete_agent_target_roll_call(&mut self, target_id: &str) { + let connected = self.sse_clients.len(); + let verdict = self + .pending_agent_targets + .iter() + .find(|(k, _)| k == target_id) + .and_then(|(_, p)| { + if p.owner.is_some() || p.reports.is_empty() || p.reports.len() < connected { + None + } else { + Some(agent_target_busy_verdict(p)) + } + }); + if let Some(verdict) = verdict { + self.resolve_agent_target(target_id, verdict); + } + } + + /// A disconnected overlay's word no longer counts: drop its busy report, + /// hand back a lease it held (a rescuer's next claim is granted at once + /// instead of after the lease lapses), and re-judge each roll call + /// against the overlays that remain. + pub fn drop_agent_target_client(&mut self, client_id: Option<&str>) { + let ids: Vec = self + .pending_agent_targets + .iter() + .map(|(k, _)| k.clone()) + .collect(); + for id in ids { + if let Some(cid) = client_id { + if let Some((_, p)) = self.pending_agent_targets.iter_mut().find(|(k, _)| *k == id) { + p.reports.retain(|r| r.client_id != cid); + if p.owner.as_deref() == Some(cid) { + p.owner = None; + p.claimed_until = 0; + } + } + } + self.maybe_complete_agent_target_roll_call(&id); + } + } + + /// Roll call plus a first-wins lease. Every connected overlay claims once. + /// A busy tab claims with eligible:false and is only counted: the moment + /// every connected overlay has reported busy, the held request answers + /// `busy` without waiting on a timer or guessing about a slower idle tab. + /// An eligible tab is granted when nobody holds the lease, when it + /// already holds it (a renew, which the holder does right before it + /// fires Go, so a lapsed lease can never leave two tabs acting), or when + /// the previous holder's lease lapsed without a result (a rescue). + /// Unknown or resolved targets deny and say so (`pending: false`), which + /// ends a rescuer's retry loop. Returns the response body. + pub fn claim_agent_target( + &mut self, + target_id: &str, + client_id: &str, + eligible: bool, + state: Value, + reason: Value, + ) -> Value { + let lease_ms = self.agent_target_lease_ms(); + let now = now_i64(); + let Some((_, pending)) = self + .pending_agent_targets + .iter_mut() + .find(|(k, _)| k == target_id) + else { + return json!({ "ok": true, "granted": false, "pending": false }); + }; + if !eligible { + pending.reports.retain(|r| r.client_id != client_id); + pending.reports.push(AgentTargetReport { + client_id: client_id.to_string(), + state, + reason, + }); + // A holder that turned busy hands the lease back, so the roll + // call can complete and an eligible tab's retry is granted at + // once instead of waiting for the lease to lapse. + if pending.owner.as_deref() == Some(client_id) { + pending.owner = None; + pending.claimed_until = 0; + } + self.maybe_complete_agent_target_roll_call(target_id); + return json!({ "ok": true, "granted": false }); + } + // An eligible claim is the client's latest word: drop any earlier + // busy report, so a busy verdict only ever counts tabs still busy. + pending.reports.retain(|r| r.client_id != client_id); + let granted = pending.owner.is_none() + || pending.owner.as_deref() == Some(client_id) + || pending.claimed_until <= now; + if granted { + pending.owner = Some(client_id.to_string()); + pending.claimed_until = now + lease_ms; + } + json!({ "ok": true, "granted": granted, "pending": true }) + } + /// JS: generationIsFenced(id) pub fn generation_is_fenced(&self, id: &str) -> bool { if id.is_empty() { @@ -1188,3 +1440,22 @@ pub fn strip_poller_owned_event_fields(event: &mut Map) { event.remove(key); } } + +/// `Number(process.env.X || '') || default`: a positive integer wins, anything +/// else falls back to the default. +fn env_positive_ms(env: &Env, key: &str) -> Option { + env.get(key) + .and_then(|v| v.trim().parse::().ok()) + .filter(|v| *v > 0) +} + +/// The busy verdict for a held target: the first report's state and reason. +pub fn agent_target_busy_verdict(pending: &AgentTargetPending) -> Value { + let first = pending.reports.first(); + json!({ + "ok": false, + "error": "busy", + "state": first.map(|r| r.state.clone()).unwrap_or(Value::Null), + "reason": first.map(|r| r.reason.clone()).unwrap_or(Value::Null), + }) +} diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 7d1f89d76..e90927079 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -685,7 +685,7 @@ Tier 2 (`staleness-deep.mjs`, doctor only): #### `pin.mjs` -> `impeccable pin` - **Invoked from**: `SKILL.src.md`: `node {{scripts_path}}/pin.mjs `; "Report the script's result concisely; relay stderr verbatim on error." -- **CLI args**: exactly `argv[2]` = action (`pin`|`unpin`), `argv[3]` = command. Missing either -> stdout `Usage: node pin.mjs ` + `\nAvailable commands: `, exit 1. Bad action -> stderr `Unknown action: . Use 'pin' or 'unpin'.`, exit 1. Bad command -> stderr `Unknown command: ` and `Available commands: ...`, exit 1. `VALID_COMMANDS = craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize` (23; `doctor`, `teach` not included). +- **CLI args**: exactly `argv[2]` = action (`pin`|`unpin`), `argv[3]` = command. Missing either -> stdout `Usage: node pin.mjs ` + `\nAvailable commands: `, exit 1. Bad action -> stderr `Unknown action: . Use 'pin' or 'unpin'.`, exit 1. Bad command -> stderr `Unknown command: ` and `Available commands: ...`, exit 1. `VALID_COMMANDS = craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize, generate` (24; `doctor`, `teach` not included). - **Env vars**: none. - **Inputs**: project root = walk up from cwd until a dir containing `package.json`, `.git`, or `skills-lock.json` (stops at `/`; falls back to cwd). Harness dirs `HARNESS_DIRS = .claude .cursor .gemini .codex .agents .agent .github .grok .hermes .trae .trae-cn .pi .opencode .kiro .rovodev .vibe .qoder`; a harness is used only if `//skills/impeccable` or `//skills/i-impeccable` exists. `command-metadata.json` next to the script (`{ [command]: { description, argumentHint } }`). - **Outputs/side effects** (`pin`): no harness dirs -> stdout `No harness directories with impeccable installed found.`, exit 0. For each harness skills dir: `//SKILL.md`; if it exists without the marker `` -> ` SKIP: (non-pinned skill already exists)`; else mkdir + write, print ` + `. Then if any created: `\nPinned '' as a standalone shortcut in location(s).` and `Use the pinned command directly in each harness.`. Content (prefix `$` and codex frontmatter when the harness dir basename is `.codex` or `.agents`, else `/`): @@ -862,6 +862,8 @@ getCachePath(cwd) = /.impeccable/hook.cache.json getPendingPath(cwd) = /.impeccable/hook.pending.json (only ever deleted by hook-admin reset; never written) ``` +LIVE_PREVIEW_MARKERS: content containing `data-impeccable-variants=` or `impeccable-carbonize-start` (the wrapper a live generate publishes and the block a carbonize accept leaves until cleanup). Every hook entry stands down on it: `hook` records `skipped:'live-preview'` for the per-edit pass and skips the file silently in the Stop pass; `hook-before-edit` allows with `skipped:'live-preview'`. + #### 0.2 Config: `readConfig(cwd)` Reads, in order, `config.json` then `config.local.json` (later wins for scalars, arrays are unioned). Each file is parsed with `JSON.parse`; a missing or malformed file is treated as `null` (silently ignored). For each file: @@ -1103,12 +1105,12 @@ Candidates in order: `/detector/detect-antipatterns.mjs` (built skill l 4. `config = readConfig(projectCwd)`; `enabled === false` → `'config-disabled'`. 5. native platform → `skipped:'native-platform', platform`. 6. `cache = readCache(projectCwd)`; `sessionId = event.session_id || 'unknown'`; detector missing → `'detector-missing'`; `scanOptions = designSystemOptions(...)`; `tiered = perEditTieringActive(config, harness)`; `quietMode = truthy(IMPECCABLE_HOOK_QUIET) || config.quiet`. - 7. For each target file (audit.file updated each iteration): skip with `lastSkip` = `'sensitive'` (contains `..` or SENSITIVE_PATH), `'generated'`, `'extension'` (not ALLOWED and not configured), `'config-ignore-file'` (`matchesAnyGlob(relativized)` or `(absolute)` vs `config.ignoreFiles`), `'file-missing'`, `'outside-project'`, `'too-large'` (records `skippedBytes`). If the file is a PRIMARY (not co-scanned): `editCount = bumpEditCount(...)`; if `editCount > 6` → if `=== 7` and no suppression winner yet → `suppressionWinner={filePath}`; `lastSkip='suppressed'`, `suppressedHit=true`, continue. Read content, run detector (throw → `findings=[]`, `detectorThrew=true`). `filtered = filterFindings(...)`; if tiered split into immediate/deferred else all immediate. If deferred non-empty → `touchFile`, `deferredTotal += n`. `fresh = dedupeAgainstCache(immediate, ...)`. `audit.findings = raw count`, `audit.freshFindings = fresh.length`, `audit.deferred = deferredTotal` (if >0). If detectorThrew → `detectorThrewAny=true`, continue (cache untouched for that file). `rememberFindings(cache, sid, file, immediate)` (replace). If fresh>0 → push `{filePath, findings: fresh}` to `freshGroups`, continue. Else if immediate>0 and no pendingWinner → `pendingWinner={filePath, known: immediate.map(findingCacheKey)}`; else if immediate==0 and no cleanWinner: if quiet or not ack-eligible → `cleanWinner={filePath}` (without consuming `cleanAcked`); else if `fileEntry.cleanAcked` → `cleanAckDeduped=true` (keep scanning); else set `cleanAcked=true`, `cleanWinner={filePath}`, `cleanAckDeduped=false`. + 7. For each target file (audit.file updated each iteration): skip with `lastSkip` = `'sensitive'` (contains `..` or SENSITIVE_PATH), `'generated'`, `'extension'` (not ALLOWED and not configured), `'config-ignore-file'` (`matchesAnyGlob(relativized)` or `(absolute)` vs `config.ignoreFiles`), `'file-missing'`, `'outside-project'`, `'too-large'` (records `skippedBytes`), and, once the content is read, `'live-preview'` (LIVE_PREVIEW_MARKERS). If the file is a PRIMARY (not co-scanned): `editCount = bumpEditCount(...)`; if `editCount > 6` → if `=== 7` and no suppression winner yet → `suppressionWinner={filePath}`; `lastSkip='suppressed'`, `suppressedHit=true`, continue. Read content, run detector (throw → `findings=[]`, `detectorThrew=true`). `filtered = filterFindings(...)`; if tiered split into immediate/deferred else all immediate. If deferred non-empty → `touchFile`, `deferredTotal += n`. `fresh = dedupeAgainstCache(immediate, ...)`. `audit.findings = raw count`, `audit.freshFindings = fresh.length`, `audit.deferred = deferredTotal` (if >0). If detectorThrew → `detectorThrewAny=true`, continue (cache untouched for that file). `rememberFindings(cache, sid, file, immediate)` (replace). If fresh>0 → push `{filePath, findings: fresh}` to `freshGroups`, continue. Else if immediate>0 and no pendingWinner → `pendingWinner={filePath, known: immediate.map(findingCacheKey)}`; else if immediate==0 and no cleanWinner: if quiet or not ack-eligible → `cleanWinner={filePath}` (without consuming `cleanAcked`); else if `fileEntry.cleanAcked` → `cleanAckDeduped=true` (keep scanning); else set `cleanAcked=true`, `cleanWinner={filePath}`, `cleanAckDeduped=false`. 8. If `freshGroups` non-empty: `text = appendDesignSystemNoteOnce(renderGroupedTemplate(freshGroups, config, {cwd:projectCwd, footer: footerModeForSession, reserveChars: designNoteReserve}), ...)`; `commitFooterShown`; **`persistCache` always** (creates `.impeccable/` if needed); return `stdout = payload(text,'PostToolUse',harness)`, audit `{..., file: firstGroup.filePath, emitted:true, freshFiles, freshFindings(total), chars, durationMs}`, `emission:{kind:'fresh', file, findings, groups}`. 9. Else compute `ack`: not quiet AND pendingWinner AND ack-eligible → `{kind:'pending', text: appendDesignSystemNoteOnce(renderPendingAck(...))}`; else not quiet AND no suppressionWinner AND cleanWinner AND !cleanAckDeduped AND ack-eligible → `{kind:'clean', text: appendDesignSystemNoteOnce(renderCleanAck(...))}`. 10. Persist cache only if `deferredTotal > 0 || (cacheDirty && exists(/.impeccable))` (a clean edit in a project with no `.impeccable/` footprint writes nothing to disk). 11. Return precedence: `detectorThrewAny && !pendingWinner && !cleanWinner` → audit `{emitted:false, error:'detector-threw'}`; quiet → `{emitted:false, quiet:true}`; pending ack → stdout payload, audit `{file, emitted:true, kind:'pending', pending:, chars}`; suppressionWinner → stdout `payload(suppressionNotice(relativize(file)))`, audit `{file, suppressed:true, emitted:true}`; clean ack → stdout payload, audit `{file, emitted:true, kind:'clean', chars}`; pendingWinner (non-UI) → `{emitted:false, skipped:'non-ui-ack'}`; cleanWinner → same `'non-ui-ack'`; cleanAckDeduped → `skipped:'clean-ack-deduped'`; suppressedHit → `{suppressed:true, emitted:false}`; else `{skipped:lastSkip, bytes?:skippedBytes (only when 'too-large')}`. Any exception → `{exitCode:0, stdout:'', audit:{..., error}}`. -- **Stop algorithm (`runStopHook`)**: re-entrancy/disabled/malformed/empty as above; `event.stop_hook_active === true` → `skipped:'stop-hook-active'` (no scan, no output; prevents Claude Code re-invocation loops, issue #400). `projectCwd = resolve(event.cwd || cwd)` (no file-based re-keying); `sessionId = event.session_id || 'unknown'`; config disabled → `'config-disabled'`; `touched = keys(cache.sessions[sid].files)`; empty → `'no-touched-files'`; native → `'native-platform'`; detector missing → `'detector-missing'`. Iterate touched files (max 20 scanned): same skips (sensitive/generated/extension/ignoreFiles/missing/outside-project) silently; read (unreadable → skip); detect with full rule set (no tiering); `filtered = filterFindings`; `fresh = dedupeAgainstCache`; if fresh → `rememberFindings(cache, sid, file, fresh)` (NOTE: replaces the file's remembered set with only the fresh ones), push group. `audit.scannedFiles`. No groups → `{emitted:false, skipped:'stop-clean'}`. Else render grouped with footer mode + reserve, `appendDesignSystemNoteOnce`, `commitFooterShown`, `persistCache`, stdout `payload(text,'Stop',harness)`, audit `{emitted:true, freshFiles, freshFindings, chars, durationMs}`, `emission:{kind:'stop-deep-pass', groups}`. +- **Stop algorithm (`runStopHook`)**: re-entrancy/disabled/malformed/empty as above; `event.stop_hook_active === true` → `skipped:'stop-hook-active'` (no scan, no output; prevents Claude Code re-invocation loops, issue #400). `projectCwd = resolve(event.cwd || cwd)` (no file-based re-keying); `sessionId = event.session_id || 'unknown'`; config disabled → `'config-disabled'`; `touched = keys(cache.sessions[sid].files)`; empty → `'no-touched-files'`; native → `'native-platform'`; detector missing → `'detector-missing'`. Iterate touched files (max 20 scanned): same skips (sensitive/generated/extension/ignoreFiles/missing/outside-project) silently; read (unreadable → skip; LIVE_PREVIEW_MARKERS in the content → skip); detect with full rule set (no tiering); `filtered = filterFindings`; `fresh = dedupeAgainstCache`; if fresh → `rememberFindings(cache, sid, file, fresh)` (NOTE: replaces the file's remembered set with only the fresh ones), push group. `audit.scannedFiles`. No groups → `{emitted:false, skipped:'stop-clean'}`. Else render grouped with footer mode + reserve, `appendDesignSystemNoteOnce`, `commitFooterShown`, `persistCache`, stdout `payload(text,'Stop',harness)`, audit `{emitted:true, freshFiles, freshFindings, chars, durationMs}`, `emission:{kind:'stop-deep-pass', groups}`. - **Outputs**: - stdout: exactly one JSON document (no trailing newline) when something is emitted, else nothing. Claude/Codex/Grok: `{"hookSpecificOutput":{"hookEventName":"PostToolUse"|"Stop","additionalContext":""}}`. Cursor-shaped: `{"additional_context":""}`. GitHub: `{"additionalContext":""}`. - stderr: only `[impeccable-hook] ` when `IMPECCABLE_HOOK_DEBUG` and an unexpected top-level error. @@ -1157,7 +1159,7 @@ DOM scans, design-system findings, co-scanned stylesheets without their own base 2. stdin parse error → `'stdin-malformed'`; empty/non-object → `'stdin-empty'`. 3. no filePath → `'no-file-path'`; outside project → `'outside-project'`; SENSITIVE → `'sensitive'`; GENERATED → `'generated'`. 4. `config = readConfig(cwd)`; ext not allowed and not configured → `'extension'`. - 5. content skip object → that reason; empty content → `'no-proposed-content'`. + 5. content skip object → that reason; empty content → `'no-proposed-content'`; content over the cap → `'content-too-large'`; LIVE_PREVIEW_MARKERS in the proposed content OR in the file on disk → `'live-preview'` (a live variant session owns files carrying preview scaffolding; nagging mid-cycle derails it, and `live-complete` verifies the file once the accepted variant is permanent). 6. `config.enabled === false` → `'config-disabled'`; native platform → `'native-platform'` (+`platform`). 7. ignoreFiles glob (relative or absolute) → `'config-ignore-file'`. 8. detector missing → `'detector-missing'`; `scanOptions = designSystemOptions`. @@ -1466,7 +1468,7 @@ Binds `127.0.0.1:PORT`. CORS: if request has `Origin` and (origin is loopback ht | `GET /design-system.json?token=` | 401 `Unauthorized` | 404 `{present:false}` if neither DESIGN.md nor `.impeccable/design.json`; else `{present:true, hasMd, hasSidecar, mdNewerThanJson, parsed?, parseError?, sidecar?, sidecarError?}` (`parsed` = parseDesignMd output; `sidecarError` = `'Failed to parse .impeccable/design.json: '+msg`) | | `GET /design-system/raw?token=` | 401 | 200 `text/markdown; charset=utf-8` DESIGN.md verbatim; 404 `Not found` | | `GET /source?token=&path=` | 401 | path required and no `..` else 400 `Bad path`; resolved must be inside cwd (relative check, not root itself) else 403 `Forbidden`; 404 `File not found`; 200 `text/html; charset=utf-8` raw file. Used by browser to read source, svelte manifest and `params.json`. | -| `GET /events?token=` (SSE) | 401 | headers `text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`; first frame `data: {"type":"connected","hasProjectContext":b,"agentPolling":b,"activeSessions":[…]}\n\n`; `: keepalive\n\n` every 30s; on connect: cancels exit timer and removes queued anonymous `exit` events. On close: if 0 clients, after 8000 ms (still 0) enqueue `{type:'exit'}`. | +| `GET /events?token=&clientId=` (SSE) | 401 | `clientId` (optional) is the overlay's per-page-load id; on close the server retires that client's agent-target roll-call report and releases a lease it held, then re-judges each pending roll call against the remaining clients. Headers `text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`; first frame `data: {"type":"connected","hasProjectContext":b,"agentPolling":b,"activeSessions":[…]}\n\n`; `: keepalive\n\n` every 30s; on connect: cancels exit timer and removes queued anonymous `exit` events. On close: if 0 clients, after 8000 ms (still 0) enqueue `{type:'exit'}`. | | `POST /events` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | see 6.1 | | `GET /stop?token=` | 401 | 200 text `stopping`, then shutdown | | `GET /poll?token=&timeout=&leaseMs=&types=` | 401 `{"error":"Unauthorized"}` | see 6.3 | @@ -1477,6 +1479,9 @@ Binds `127.0.0.1:PORT`. CORS: if request has `Origin` and (origin is loopback ht | `POST /manual-edit-repair-decision` (token body or query) | 401 | see 10 | | `POST /manual-edit-discard?token=&pageUrl=` | 401 | see 10 | | `POST /manual-edit` | | 410 `{"error":"/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits."}` | +| `POST /agent-target` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | Agent-initiated targeting (the `generate` command). Validation (400 `{"error":}`, messages verbatim): `agent_target: selector is required`, `agent_target: selector too long` (>1000 chars), `agent_target: invalid action (valid: )`, `agent_target: count must be 1-8`, `agent_target: text must be a string of at most 500 chars`, `agent_target: index must be a positive integer (1-based)`, `agent_target: prompt must be a string of at most 2000 chars`, `agent_target: dryRun must be a boolean`. No SSE client → 200 `{ok:false, error:'no_browser_connected'}`. Otherwise mint an 8-hex `targetId`, broadcast `agent_target` (see 6.2), and **hold the response** until `/agent-target-result` resolves it, every connected overlay has declined (busy roll call, see `/agent-target-claim`), or `IMPECCABLE_AGENT_TARGET_TIMEOUT_MS` (default 15000) elapses: busy verdict `{ok:false, error:'busy', state, reason}` from the first report when any report exists, else `{ok:false, error:'browser_timeout', timeoutMs}`. The held reply is 200 `{targetId, ...result}`; shutdown resolves every held request with `{ok:false, error:'server_stopping'}`. | +| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). | +| `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason}` under `clientId` (replacing an earlier report), release the lease if this client holds it, answer `{ok:true, granted:false}`, then complete the roll call when no owner holds the lease and reports ≥ connected SSE clients (verdict from the first report). `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | | anything else | | 404 `Not found` | Pending-event summary in `/status.pendingEvents[]`: `{id, type, leased:boolean, leaseUntil:number|null}` plus for `manual_edit_apply`: `pageUrl, chunk, repair, evidencePath, agentAction, manualApplySummary:{pageUrl, chunk, entryCount, opCount, files[]}`. @@ -1534,7 +1539,7 @@ Missed-completion redelivery: on a `checkpoint` with `phase==='generating'` and Generation checkpoint recording (only for reasons `variants_progress|variants_ready`, arrived>0, expected>0, session not canceled): broadcast `{type:'variant_progress', id, file: previewFile||file, sourceFile, previewFile, previewMode ('source' default), arrivedVariants, expectedVariants, publicationKind: event.publicationKind||'variants'}` and record agent phases `first_reviewable` (once), `second_reviewable` (arrived≥2 && expected≥3, once), `all_variants_ready` (arrived≥expected, once), each `{arrivedVariants, expectedVariants, checkpointReason, at}`. #### 6.2 Server → browser (SSE `data:` JSON frames) -`connected`, `agent_polling {connected}`, `agent_phase {id, phase, at, durationMs?, previewMode?, owner?}`, `variant_progress {…}`, `done`/`steer_done`/`complete`/`agent_done`/`discarded`/`error`/`discard`/any reply type: `{type: msg.type||'done', id, message, file, sourceFile, previewFile, previewMode, data}` (forwarded from `POST /poll`), redelivered `done`, manual-edit activity entries `{seq, type:'manual_edit_*', ts, …details}`. +`connected`, `agent_polling {connected}`, `agent_phase {id, phase, at, durationMs?, previewMode?, owner?}`, `variant_progress {…}`, `agent_target {targetId, selector, text?, index?, action, count, prompt?, dryRun?}` (pushed by `POST /agent-target` and replayed to every overlay that connects while the target is pending), `done`/`steer_done`/`complete`/`agent_done`/`discarded`/`error`/`discard`/any reply type: `{type: msg.type||'done', id, message, file, sourceFile, previewFile, previewMode, data}` (forwarded from `POST /poll`), redelivered `done`, manual-edit activity entries `{seq, type:'manual_edit_*', ts, …details}`. Manual-edit activity types broadcast: `manual_edit_stashed, manual_edit_discarded, manual_edit_commit_started, manual_edit_apply_dispatched, manual_edit_apply_reply_received, manual_edit_apply_reply_invalid, manual_edit_apply_stale_reply_rejected, manual_edit_apply_timeout, manual_edit_repair_needs_decision, manual_edit_repair_rollback_done, manual_edit_commit_done, manual_edit_commit_failed, manual_edit_transaction_rolled_back, manual_edit_poll_reply_unknown`. @@ -1792,6 +1797,13 @@ Conventions: every script's "run directly" guard is `process.argv[1]` ending wit #### `live-target.mjs` - Library only (`resolveLiveTarget(cwd,args)` → `{originalCwd, projectRoot, targetPath, absoluteTargetPath, targetOptions}`); used by `live.mjs`. Tests: `tests/live-target-context.test.mjs`. +#### `live-generate.mjs` -> `impeccable live-generate` +- **Invoked from**: `skill/reference/generate.md` (the `generate` command), after `impeccable live` booted the helper and the agent opened the app page: `impeccable live-generate --selector "section.pricing" --action bolder --count 3`. +- **Args**: `--selector ` (required), `--text `, `--index ` (1-based), `--action ` (default `impeccable`), `--count ` (default 3, 1-8), `--prompt `, `--dry-run`, `--wait-for-browser `, `--target ` (consumed by `enterLiveRoot`), `--help`. A flag without a value → stdout `{"ok":false,"error":"missing_flag_value","flag":"--x"}`, exit 1. +- **Env**: `IMPECCABLE_SELF` (how the boot and poll verbs are spelled in `_instructions`). +- **Behavior**: `enterLiveRoot`; local verdicts first, each pretty-printed JSON on stdout with `_instructions`, exit 1: `selector_required`, `invalid_action` (+`action`, `validActions`), `invalid_count` (+`count`), `invalid_index` (+`index`), `invalid_wait` (+`wait`); no `server.json` (or one without port/token) → `server_not_running`. With `--wait-for-browser`, `GET /status` once a second until `connectedClients > 0` or the budget ends (`no_browser_connected` + `waitedMs`); an unanswered `/status` → `server_unreachable`. Then `POST /agent-target` with `{token, selector, action, count, text?, index?, prompt?, dryRun?}` under a 20 s client cap: a transport timeout → `request_timeout` (+`detail`, browser_timeout instructions), any other transport failure → `server_unreachable` (+`detail`); a non-2xx answer → `{ok:false, error:>, ...body}`; an unparseable body → `bad_server_response` (+`status`). A 2xx answer is printed as received plus `_instructions` for `ok` (dry run or started session, naming `impeccable live-poll`), `no_browser_connected`, `browser_timeout`, `invalid_selector`, `no_match` (wording depends on `rawMatchCount`), `ambiguous`, `index_out_of_range`, `busy`, `go_failed`, `server_stopping`; exit 0 when `ok:true`, else 1. `_instructions` are regenerated locally from the verdict, never taken from the wire. +- **Tests**: `tests/oracle/cases/live-generate.mjs` (local verdicts, no-browser), `tests/live-agent-target.test.mjs` (protocol matrix against the binary), `crates/cli/tests/agent_target.rs`, `tests/live-e2e.test.mjs` (`agentTargetScenario`). + #### `live-commit-manual-edits.mjs` -> `impeccable commit-manual-edits` - Invoked by `/manual-edit-commit` (server) and manually (`node live-commit-manual-edits.mjs [--page-url=] [--provider=auto|codex|claude|mock]`). live.md/status hint: never run it for a leased chat Apply event. - Env: `IMPECCABLE_LIVE_COPY_AGENT`, `IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS`, `IMPECCABLE_LIVE_COPY_AGENT_MODEL`, `IMPECCABLE_LIVE_COPY_AGENT_EFFORT`, `IMPECCABLE_LIVE_COPY_AGENT_MOCK_RESULT`, `IMPECCABLE_LIVE_COPY_AGENT_MOCK_WRITES`, `IMPECCABLE_LIVE_COPY_AGENT_MOCK_DELAY_MS`, `IMPECCABLE_LIVE_MANUAL_EDIT_REPAIR_ATTEMPTS`. diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index fa0de35af..b2246f8fe 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -140,12 +140,14 @@ export const SUITES = { /^skill\/(reference\/live\.md|scripts\/live-browser)/, /^tests\/live-e2e\//, /^tests\/lib\/engine-bin\.mjs$/, + /^tests\/live-agent-target\.test\.mjs$/, ], commands: [ { runner: 'node', files: [ 'tests/live-reference.test.mjs', + 'tests/live-agent-target.test.mjs', 'tests/live-browser-ignores.test.mjs', 'tests/live-browser-source.test.mjs', 'tests/live-e2e-agent-output.test.mjs', diff --git a/skill/reference/generate.md b/skill/reference/generate.md index a12b2d9dd..4746a108b 100644 --- a/skill/reference/generate.md +++ b/skill/reference/generate.md @@ -8,7 +8,7 @@ Three prohibitions cover the known ways this command goes wrong. Each names the - The poll shows no generate event yet, and writing variants straight into source feels faster. **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go), and the server refuses events for any other id; a missing event is fixed in Step 2 or Step 3, never with a direct source edit. - Handing the user a link to click feels polite. **Open the page yourself** (Step 2); a pasted link usually means no page ever connects. -- The design hook may flag the preview scaffolding you just published. **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; `live-complete.mjs` verifies the file once the accepted variant is permanent. Current hooks stand down on the markers themselves; older installed hooks may still nag. +- The design hook may flag the preview scaffolding you just published. **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; `impeccable live-complete` verifies the file once the accepted variant is permanent. Current hooks stand down on the markers themselves; older installed hooks may still nag. ## Step 1: Parse the request @@ -38,7 +38,7 @@ Done when you hold an action from the vocabulary, a count from 1 to 8, and the e Run the boot exactly as [live.md](live.md)'s Start section describes: ```bash -node {{scripts_path}}/live.mjs +{{scripts_path}}/impeccable live ``` **`config_missing` / `config_invalid`**: follow [live-setup.md](live-setup.md) first. @@ -56,7 +56,7 @@ Done when the boot printed `"ok": true` and a page with the overlay is connected Derive the selector from project source, not from guesswork: an id first, then a unique class, then a landmark tag plus class. **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so scoped CSS restyles every instance at once. **Unsure the selector resolves uniquely**: probe with `--dry-run`; it resolves and reports without starting anything, and it works even mid-session. ```bash -node {{scripts_path}}/live-generate.mjs --selector "section.pricing" --action bolder --count 3 +{{scripts_path}}/impeccable live-generate --selector "section.pricing" --action bolder --count 3 ``` Flags: `--selector` (required), `--action`, `--count`, `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches), `--dry-run`, `--wait-for-browser `. @@ -78,10 +78,10 @@ Then tell the user, in one line, where their variants are: *"Three [bolder] vari ## Step 5: Close the session -Generate is a one-shot command; this is where it diverges from an open-ended `live` session. Once the accept (or discard) completes, wrap up without being asked: carbonize cleanup is done and `live-complete.mjs` printed `phase: "completed"` (a discard needs no cleanup), so kill your background poll and run live.md's Cleanup: +Generate is a one-shot command; this is where it diverges from an open-ended `live` session. Once the accept (or discard) completes, wrap up without being asked: carbonize cleanup is done and `impeccable live-complete` printed `phase: "completed"` (a discard needs no cleanup), so kill your background poll and run live.md's Cleanup: ```bash -node {{scripts_path}}/live-server.mjs stop +{{scripts_path}}/impeccable live-server stop ``` Stopping removes the injected live script, and that removal reloads the page one last time: the user's browser now shows the accepted design with no overlay chrome, still served by their dev server. diff --git a/skill/scripts/live-generate.mjs b/skill/scripts/live-generate.mjs deleted file mode 100644 index 8ed13c1ce..000000000 --- a/skill/scripts/live-generate.mjs +++ /dev/null @@ -1,227 +0,0 @@ -#!/usr/bin/env node -/** - * Agent-initiated element targeting for the `generate` command. - * - * Asks the live overlay to find an element by CSS selector, scroll to it, - * enter the picked state, and fire the normal Go pipeline with the given - * action and count. On success the browser starts a standard generate - * session; the agent then handles the resulting `generate` event from the - * poll loop exactly as live.md describes. Requires a running live helper - * server (live.mjs boot) and an open page with the overlay attached. - * - * Usage: - * node /live-generate.mjs --selector "section.pricing" --action bolder --count 3 - * node /live-generate.mjs --selector ".card" --text "Studio" --action impeccable --prompt "warmer" - * - * Flags: - * --selector required; resolved with document.querySelectorAll - * --text optional; keeps only matches whose textContent contains it - * --index optional; 1-based pick among the remaining matches - * --action optional; one of the live action vocabulary (default: impeccable) - * --count optional; variants to request, 1-8 (default: 3) - * --prompt optional; freeform direction, same as typing before Go - * --dry-run optional; resolve and report without starting anything - * --wait-for-browser optional; poll the helper until a page with the - * overlay connects (or the budget runs out) before - * sending the target. For harnesses with no browser - * tool: hand the user the URL, run with this flag, and - * the command fires as soon as they open the page. - */ - -import process from 'node:process'; -import { enterLiveRoot } from './live/roots.mjs'; -import { VISUAL_ACTIONS } from './live/vocabulary.mjs'; -import { readLiveServerInfo } from './lib/impeccable-paths.mjs'; - -enterLiveRoot(process.cwd()); - -// Destroy fetch's global undici dispatcher before process.exit(): a live -// keep-alive socket trips a libuv assertion on Windows/Node 24 after a -// successful print (nodejs/node#56645, issue #573), matching context.mjs. -async function destroyFetchDispatcher() { - const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')]; - if (dispatcher && typeof dispatcher.destroy === 'function') { - try { await dispatcher.destroy(); } catch { /* exit regardless */ } - } -} - -async function fail(payload) { - console.log(JSON.stringify(payload, null, 2)); - await destroyFetchDispatcher(); - process.exit(1); -} - -function parseArgs(argv) { - const args = {}; - for (let i = 0; i < argv.length; i += 1) { - const arg = argv[i]; - if (!arg.startsWith('--')) continue; - const key = arg.slice(2); - if (key === 'dry-run') { args['dry-run'] = true; continue; } - const value = argv[i + 1]; - if (value === undefined || value.startsWith('--')) { - // Pre-fetch validation inside a sync helper: no socket can exist yet, - // so a plain synchronous exit is safe here. - console.log(JSON.stringify({ ok: false, error: 'missing_flag_value', flag: arg }, null, 2)); - process.exit(1); - } - args[key] = value; - i += 1; - } - return args; -} - -const args = parseArgs(process.argv.slice(2)); - -const selector = (args.selector || '').trim(); -if (!selector) { - await fail({ - ok: false, - error: 'selector_required', - _instructions: 'Pass --selector with a CSS selector for the element to target. Derive it from the page source: prefer an id, a unique class, or a landmark section, and add --text "" when the class repeats.', - }); -} - -const action = args.action || 'impeccable'; -if (!VISUAL_ACTIONS.includes(action)) { - await fail({ - ok: false, - error: 'invalid_action', - action, - validActions: VISUAL_ACTIONS, - _instructions: 'Map the request wording onto the closest listed action (bold -> bolder, quiet/calmer -> quieter, simplify -> distill). When no action fits, use --action impeccable and carry the wording via --prompt.', - }); -} - -const count = args.count === undefined ? 3 : Number(args.count); -if (!Number.isInteger(count) || count < 1 || count > 8) { - await fail({ ok: false, error: 'invalid_count', count: args.count, _instructions: 'Pass --count as an integer from 1 to 8.' }); -} - -let index; -if (args.index !== undefined) { - index = Number(args.index); - if (!Number.isInteger(index) || index < 1) { - await fail({ ok: false, error: 'invalid_index', index: args.index, _instructions: 'Pass --index as a 1-based integer position among the matches.' }); - } -} - -let waitForBrowserMs = 0; -if (args['wait-for-browser'] !== undefined) { - waitForBrowserMs = Number(args['wait-for-browser']); - if (!Number.isInteger(waitForBrowserMs) || waitForBrowserMs < 1) { - await fail({ ok: false, error: 'invalid_wait', wait: args['wait-for-browser'], _instructions: 'Pass --wait-for-browser as a positive integer of milliseconds, e.g. --wait-for-browser 120000.' }); - } -} - -const found = readLiveServerInfo(process.cwd()); -if (!found || !found.info || !found.info.port || !found.info.token) { - await fail({ - ok: false, - error: 'server_not_running', - _instructions: 'No live helper server is recorded for this project. Run the live boot first (node /live.mjs), open the app URL that serves a pageFiles entry, then rerun this command.', - }); -} - -const { port, token } = found.info; - -const INSTRUCTIONS = { - ok: (r) => (r.dryRun - ? `Dry run only: the selector resolves to one element (${r.element?.tag}${r.element?.id ? '#' + r.element.id : ''}) and no session was started. Rerun without --dry-run to generate.` - : `Session ${r.sessionId} started: the browser scrolled to the target and fired Go (action "${r.action}", count ${r.count}). Poll now with live-poll.mjs; the next event for this session is its generate event. Handle it exactly per live.md's Handle generate, then reply done and keep polling.`), - no_browser_connected: () => 'No page with the live overlay is connected. Open the app URL that serves a pageFiles entry yourself with your harness browser tool, then rerun this command. Only when no browser tool exists: give the user the URL and rerun with --wait-for-browser 120000 so the command fires as soon as they open the page.', - browser_timeout: () => 'The overlay did not answer in time. The page may be mid-reload: run live-status.mjs to check whether a session started anyway, reload the app page, then rerun this command.', - invalid_selector: () => 'The selector is not valid CSS. Fix the selector syntax and rerun.', - no_match: (r) => (r.rawMatchCount > 0 - ? `The selector hit ${r.rawMatchCount} node(s) but none is pickable (too small, chrome, or filtered by --text). Target a larger element or adjust --text.` - : 'The selector matched nothing on the open page. Derive a better selector from the page source (an id, a unique class, or a landmark), or add --text with a snippet of the element\'s visible text.'), - ambiguous: (r) => `The selector matched ${r.matchCount} elements. Either target their common container instead, or disambiguate with --text "" or --index <1-based position>. The candidates are listed in this output.`, - index_out_of_range: (r) => `--index is out of range: only ${r.matchCount} match(es). Use an index from 1 to ${r.matchCount}.`, - busy: (r) => `A live session is already mid-flight (browser state ${r.state}). Let the user finish or discard it in the browser, or handle the pending event in your poll loop, then rerun.`, - go_failed: (r) => `The overlay could not start generation from the picked state (browser state ${r.state}). Reload the app page and rerun this command.`, - server_stopping: () => 'The live helper server is shutting down. Re-run the live boot (live.mjs), reopen the page, then rerun this command.', -}; - -async function waitForBrowserConnection(budgetMs) { - const deadline = Date.now() + budgetMs; - for (;;) { - let status; - try { - const res = await fetch(`http://127.0.0.1:${port}/status?token=${token}`, { - signal: AbortSignal.timeout(5_000), - }); - status = await res.json(); - } catch (err) { - await fail({ - ok: false, - error: 'server_unreachable', - detail: err?.message, - _instructions: 'The recorded live server did not answer while waiting for a browser; it likely died. Re-run the live boot (node /live.mjs), reopen the app page, then rerun this command.', - }); - } - if ((status.connectedClients || 0) > 0) return; - if (Date.now() >= deadline) { - await fail({ - ok: false, - error: 'no_browser_connected', - waitedMs: budgetMs, - _instructions: INSTRUCTIONS.no_browser_connected(), - }); - } - await new Promise((r) => setTimeout(r, 1_000)); - } -} - -async function main() { - if (waitForBrowserMs > 0) await waitForBrowserConnection(waitForBrowserMs); - const body = { - token, - selector, - action, - count, - ...(args.text ? { text: args.text } : {}), - ...(index !== undefined ? { index } : {}), - ...(args.prompt ? { prompt: args.prompt } : {}), - ...(args['dry-run'] ? { dryRun: true } : {}), - }; - let res; - try { - res = await fetch(`http://127.0.0.1:${port}/agent-target`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - // Client-side cap just above the server's 15s hold, so a hung helper - // still fails fast. - signal: AbortSignal.timeout(20_000), - }); - } catch (err) { - const timedOut = err?.name === 'TimeoutError' || err?.name === 'AbortError'; - await fail({ - ok: false, - error: timedOut ? 'request_timeout' : 'server_unreachable', - detail: err?.message, - _instructions: timedOut - ? INSTRUCTIONS.browser_timeout() - : 'The recorded live server did not answer; it likely died. Re-run the live boot (node /live.mjs), reopen the app page, then rerun this command.', - }); - } - let result; - try { - result = await res.json(); - } catch { - await fail({ ok: false, error: 'bad_server_response', status: res.status }); - } - if (!res.ok) { - await fail({ ok: false, error: result.error || `http_${res.status}`, ...result }); - } - const instructions = INSTRUCTIONS[result.ok ? 'ok' : result.error]; - const output = { - ...result, - ...(instructions ? { _instructions: instructions(result) } : {}), - }; - console.log(JSON.stringify(output, null, 2)); - await destroyFetchDispatcher(); - process.exit(result.ok ? 0 : 1); -} - -main().catch((err) => fail({ ok: false, error: 'unexpected_failure', detail: err?.message })); diff --git a/tests/live-agent-target.test.mjs b/tests/live-agent-target.test.mjs index c2239221a..6d0ffcaca 100644 --- a/tests/live-agent-target.test.mjs +++ b/tests/live-agent-target.test.mjs @@ -1,8 +1,9 @@ /** - * Tests for agent-initiated element targeting (the `generate` command): - * POST /agent-target held-open pairing with POST /agent-target-result, - * validation, the no-browser and timeout verdicts, and the live-generate CLI's - * local failure modes. + * Protocol tests for agent-initiated element targeting (the `generate` + * command), driven against the engine binary: POST /agent-target held-open + * pairing with POST /agent-target-result, validation, the roll call and its + * leases, the no-browser and timeout verdicts, and the live-generate verb's + * local failure modes. Skips cleanly without a binary (tests/lib/engine-bin.mjs). * * Run with: node --test tests/live-agent-target.test.mjs */ @@ -14,37 +15,66 @@ import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { execFile, execFileSync, spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { getLiveServerPath } from '../skill/scripts/lib/impeccable-paths.mjs'; -import { VISUAL_ACTIONS } from '../skill/scripts/live/vocabulary.mjs'; +import { ENGINE_MISSING_MESSAGE, engineEnv, findEngineBinary } from './lib/engine-bin.mjs'; // Resolve the repo from this file, not from cwd: the runner may be invoked // from tests/ or anywhere else. const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); -const SERVER_SCRIPT = join(REPO_ROOT, 'skill/scripts/live-server.mjs'); -const GENERATE_SCRIPT = join(REPO_ROOT, 'skill/scripts/live-generate.mjs'); +const ENGINE_BIN = findEngineBinary(); + +// The action vocabulary lives in the engine (crates/live/src/vocabulary.rs); +// read it from the Rust source so the matrix below can never drift from what +// the live server accepts. +function readVisualActions() { + const rust = readFileSync(join(REPO_ROOT, 'crates/live/src/vocabulary.rs'), 'utf-8'); + const block = rust.match(/pub const VISUAL_ACTIONS: \[&str; (\d+)\] = \[([\s\S]*?)\];/); + if (!block) throw new Error('VISUAL_ACTIONS not found in crates/live/src/vocabulary.rs'); + return [...block[2].matchAll(/"([a-z]+)"/g)].map((m) => m[1]); +} +const VISUAL_ACTIONS = readVisualActions(); + +function liveServerPath(cwd) { + return join(cwd, '.impeccable/live/server.json'); +} + +/** Run the live-generate verb; the JSON verdict is on stdout on every exit code. */ +function runGenerate(cwd, args) { + return execFileSync(ENGINE_BIN, ['live-generate', ...args], { + cwd, + encoding: 'utf-8', + env: engineEnv(ENGINE_BIN, {}), + }); +} function startServer(port, { cwd, env = {} } = {}) { return new Promise((resolve, reject) => { - const proc = spawn('node', [SERVER_SCRIPT, '--port=' + port], { + const proc = spawn(ENGINE_BIN, ['live-server', '--port=' + port], { cwd, stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, IMPECCABLE_LIVE_COPY_AGENT: 'off', ...env }, + env: engineEnv(ENGINE_BIN, { IMPECCABLE_LIVE_COPY_AGENT: 'off', ...env }), }); let output = ''; - proc.stdout.on('data', (d) => { - output += d.toString(); - if (output.includes('running on')) { - try { - const info = JSON.parse(readFileSync(getLiveServerPath(cwd), 'utf-8')); - resolve({ proc, port: info.port, token: info.token, cwd }); - } catch { - reject(new Error('Server started but PID file not readable')); - } - } - }); + proc.stdout.on('data', (d) => { output += d.toString(); }); proc.stderr.on('data', (d) => { output += d.toString(); }); proc.on('error', reject); - setTimeout(() => reject(new Error('Server start timeout. Output: ' + output)), 5000); + // The server writes server.json on listen; poll for it rather than + // parsing the banner, so a slow first start still resolves. + const deadline = Date.now() + 10_000; + const tick = () => { + try { + const info = JSON.parse(readFileSync(liveServerPath(cwd), 'utf-8')); + if (info.port && info.token) { + resolve({ proc, port: info.port, token: info.token, cwd }); + return; + } + } catch { /* not yet */ } + if (Date.now() > deadline) { + reject(new Error('Server start timeout. Output: ' + output)); + return; + } + setTimeout(tick, 50); + }; + tick(); }); } @@ -116,7 +146,7 @@ async function openSseClient(server, { clientId } = {}) { }; } -describe('POST /agent-target', () => { +describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSAGE }, () => { let tmp; let server; @@ -606,9 +636,9 @@ describe('POST /agent-target', () => { for (const action of VISUAL_ACTIONS) { const cli = new Promise((resolve) => { execFile( - process.execPath, - [GENERATE_SCRIPT, '--selector', 'h1', '--action', action, '--dry-run'], - { cwd: tmp, encoding: 'utf-8' }, + ENGINE_BIN, + ['live-generate', '--selector', 'h1', '--action', action, '--dry-run'], + { cwd: tmp, encoding: 'utf-8', env: engineEnv(ENGINE_BIN, {}) }, (err, stdout) => resolve({ code: err ? err.code : 0, stdout }), ); }); @@ -666,7 +696,7 @@ describe('POST /agent-target', () => { }); }); -describe('live-generate CLI --wait-for-browser', () => { +describe('live-generate CLI --wait-for-browser', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSAGE }, () => { let tmp; let server; @@ -687,10 +717,7 @@ describe('live-generate CLI --wait-for-browser', () => { function runCli(cwd, args) { try { - const stdout = execFileSync(process.execPath, [GENERATE_SCRIPT, ...args], { - cwd, - encoding: 'utf-8', - }); + const stdout = runGenerate(cwd, args); return { code: 0, json: JSON.parse(stdout) }; } catch (err) { return { code: err.status, json: JSON.parse(err.stdout) }; @@ -721,9 +748,9 @@ describe('live-generate CLI --wait-for-browser', () => { // and the delayed connect would never happen. const child = new Promise((resolve) => { execFile( - process.execPath, - [GENERATE_SCRIPT, '--selector', 'h1', '--action', 'bolder', '--wait-for-browser', '10000'], - { cwd: tmp, encoding: 'utf-8' }, + ENGINE_BIN, + ['live-generate', '--selector', 'h1', '--action', 'bolder', '--wait-for-browser', '10000'], + { cwd: tmp, encoding: 'utf-8', env: engineEnv(ENGINE_BIN, {}) }, (err, stdout) => resolve({ code: err ? err.code : 0, stdout }), ); }); @@ -737,13 +764,10 @@ describe('live-generate CLI --wait-for-browser', () => { }); }); -describe('live-generate CLI local failure modes', () => { +describe('live-generate CLI local failure modes', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSAGE }, () => { function runCli(cwd, args) { try { - const stdout = execFileSync(process.execPath, [GENERATE_SCRIPT, ...args], { - cwd, - encoding: 'utf-8', - }); + const stdout = runGenerate(cwd, args); return { code: 0, json: JSON.parse(stdout) }; } catch (err) { return { code: err.status, json: JSON.parse(err.stdout) }; @@ -756,7 +780,7 @@ describe('live-generate CLI local failure modes', () => { const { code, json } = runCli(tmp, ['--selector', 'h1', '--action', 'bolder']); assert.equal(code, 1); assert.equal(json.error, 'server_not_running'); - assert.match(json._instructions, /live\.mjs/); + assert.match(json._instructions, / live\)/, 'names the boot verb'); } finally { rmSync(tmp, { recursive: true, force: true }); } diff --git a/tests/live-e2e.test.mjs b/tests/live-e2e.test.mjs index 885552793..51ecd6d4a 100644 --- a/tests/live-e2e.test.mjs +++ b/tests/live-e2e.test.mjs @@ -885,10 +885,11 @@ for (const { name, fixture } of fixtures) { if (scenario.prompt) { // The configure bar rebuild once discarded the preset prompt, so // pin the regression at the wire: the journaled generate event - // must carry the prompt the CLI was given. - const journalPath = join(appRoot, '.impeccable/live/sessions', `${res.sessionId}.jsonl`); - const journaled = readFileSync(journalPath, 'utf-8').trim().split('\n').map((l) => JSON.parse(l)); - const generateEvent = journaled.find((entry) => entry.type === 'generate')?.event; + // must carry the prompt the CLI was given. The engine journals a + // generate event when the agent leases it from /poll, so wait + // for the entry instead of reading the journal right away. + const [journaled] = await waitForJournalEvent(appRoot, res.sessionId, 'generate'); + const generateEvent = journaled?.event ?? journaled; assert.equal( generateEvent?.freeformPrompt, scenario.prompt, diff --git a/tests/live-reference.test.mjs b/tests/live-reference.test.mjs index 28be9e42b..3e397135a 100644 --- a/tests/live-reference.test.mjs +++ b/tests/live-reference.test.mjs @@ -3,7 +3,6 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { compileProviderBlocks } from '../scripts/lib/utils.js'; -import { VISUAL_ACTIONS } from '../skill/scripts/live/vocabulary.mjs'; const ROOT = process.cwd(); @@ -178,8 +177,20 @@ describe('live reference authoring contract', () => { // value the picker offers but the reference never names is a request // the agent cannot route. const generateMd = readFileSync(join(ROOT, 'skill/reference/generate.md'), 'utf-8'); - for (const action of VISUAL_ACTIONS) { + for (const action of readVisualActions()) { assert.match(generateMd, new RegExp('`' + action + '`'), `generate.md must name \`${action}\``); } }); }); + +// The action vocabulary lives in the engine (crates/live/src/vocabulary.rs); +// read it from the Rust source so the parity check needs no binary and can +// never drift from what the live server accepts. +function readVisualActions() { + const rust = readFileSync(join(ROOT, 'crates/live/src/vocabulary.rs'), 'utf-8'); + const block = rust.match(/pub const VISUAL_ACTIONS: \[&str; (\d+)\] = \[([\s\S]*?)\];/); + if (!block) throw new Error('VISUAL_ACTIONS not found in crates/live/src/vocabulary.rs'); + const actions = [...block[2].matchAll(/"([a-z]+)"/g)].map((m) => m[1]); + if (actions.length !== Number(block[1])) throw new Error('VISUAL_ACTIONS length mismatch'); + return actions; +} diff --git a/tests/oracle/cases/live-generate.mjs b/tests/oracle/cases/live-generate.mjs new file mode 100644 index 000000000..490822aa7 --- /dev/null +++ b/tests/oracle/cases/live-generate.mjs @@ -0,0 +1,44 @@ +/** + * `live-generate` (the `generate` command's agent-initiated targeting): the + * verdicts the verb decides locally, without a browser, plus the one the + * helper answers when no overlay is attached. Everything that needs an + * overlay (the roll call, leases, replay) is covered by + * tests/live-agent-target.test.mjs and crates/cli/tests/agent_target.rs. + */ +import { LIVE_FILES } from '../live-helpers.mjs'; + +const NORM = [ + ['localhost:\\d{4,5}', 'g', 'localhost:'], + ['"(port|serverPort)":(\\s*)\\d{4,5}', 'g', '"$1":$2'], + ['Stopped live server on port \\d+\\.', 'g', 'Stopped live server on port .'], +]; + +export default [ + { + id: 'live-generate-local-verdicts', workspace: 'live-html', files: [...LIVE_FILES], + // No helper is recorded in the staged workspace, so every step short + // of a valid request ends in the verb's own verdict, and the valid one + // ends in server_not_running. + steps: [ + { verb: 'live-generate', args: ['--help'] }, + { verb: 'live-generate', args: [] }, + { verb: 'live-generate', args: ['--selector'] }, + { verb: 'live-generate', args: ['--selector', 'h1', '--action', 'bold'] }, + { verb: 'live-generate', args: ['--selector', 'h1', '--count', '9'] }, + { verb: 'live-generate', args: ['--selector', 'h1', '--count', 'three'] }, + { verb: 'live-generate', args: ['--selector', 'h1', '--index', '0'] }, + { verb: 'live-generate', args: ['--selector', 'h1', '--wait-for-browser', 'soon'] }, + { verb: 'live-generate', args: ['--selector', 'h1', '--action', 'bolder'] }, + ], + }, + { + id: 'live-generate-no-browser-connected', workspace: 'live-html', files: [...LIVE_FILES], normalize: NORM, + // A running helper with no overlay attached answers at once instead of + // holding the request. + steps: [ + { verb: 'live-server', daemon: true, readyFile: '.impeccable/live/server.json', readyTimeoutMs: 15000 }, + { verb: 'live-generate', args: ['--selector', 'h1', '--action', 'bolder', '--count', '2', '--prompt', 'warmer'] }, + { verb: 'live-server', args: ['stop'] }, + ], + }, +]; diff --git a/tests/oracle/golden/live-generate-local-verdicts.json b/tests/oracle/golden/live-generate-local-verdicts.json new file mode 100644 index 000000000..b7f0170d0 --- /dev/null +++ b/tests/oracle/golden/live-generate-local-verdicts.json @@ -0,0 +1,61 @@ +{ + "steps": [ + { + "stdout": "Usage: impeccable live-generate --selector [--text ] [--index ] [--action ] [--count ] [--prompt ] [--dry-run] [--wait-for-browser ]\n\nFlags:\n --selector required; resolved with document.querySelectorAll\n --text optional; keeps only matches whose textContent contains it\n --index optional; 1-based pick among the remaining matches\n --action optional; one of the live action vocabulary (default: impeccable)\n --count optional; variants to request, 1-8 (default: 3)\n --prompt optional; freeform direction, same as typing before Go\n --dry-run optional; resolve and report without starting anything\n --wait-for-browser optional; poll the helper until a page with the\n overlay connects (or the budget runs out) before sending\n the target.\n\n", + "stderr": "", + "exit": 0, + "signal": null + }, + { + "stdout": "{\n \"ok\": false,\n \"error\": \"selector_required\",\n \"_instructions\": \"Pass --selector with a CSS selector for the element to target. Derive it from the page source: prefer an id, a unique class, or a landmark section, and add --text \\\"\\\" when the class repeats.\"\n}\n", + "stderr": "", + "exit": 1, + "signal": null + }, + { + "stdout": "{\n \"ok\": false,\n \"error\": \"missing_flag_value\",\n \"flag\": \"--selector\"\n}\n", + "stderr": "", + "exit": 1, + "signal": null + }, + { + "stdout": "{\n \"ok\": false,\n \"error\": \"invalid_action\",\n \"action\": \"bold\",\n \"validActions\": [\n \"impeccable\",\n \"bolder\",\n \"quieter\",\n \"distill\",\n \"polish\",\n \"typeset\",\n \"colorize\",\n \"layout\",\n \"adapt\",\n \"animate\",\n \"delight\",\n \"overdrive\"\n ],\n \"_instructions\": \"Map the request wording onto the closest listed action (bold -> bolder, quiet/calmer -> quieter, simplify -> distill). When no action fits, use --action impeccable and carry the wording via --prompt.\"\n}\n", + "stderr": "", + "exit": 1, + "signal": null + }, + { + "stdout": "{\n \"ok\": false,\n \"error\": \"invalid_count\",\n \"count\": \"9\",\n \"_instructions\": \"Pass --count as an integer from 1 to 8.\"\n}\n", + "stderr": "", + "exit": 1, + "signal": null + }, + { + "stdout": "{\n \"ok\": false,\n \"error\": \"invalid_count\",\n \"count\": \"three\",\n \"_instructions\": \"Pass --count as an integer from 1 to 8.\"\n}\n", + "stderr": "", + "exit": 1, + "signal": null + }, + { + "stdout": "{\n \"ok\": false,\n \"error\": \"invalid_index\",\n \"index\": \"0\",\n \"_instructions\": \"Pass --index as a 1-based integer position among the matches.\"\n}\n", + "stderr": "", + "exit": 1, + "signal": null + }, + { + "stdout": "{\n \"ok\": false,\n \"error\": \"invalid_wait\",\n \"wait\": \"soon\",\n \"_instructions\": \"Pass --wait-for-browser as a positive integer of milliseconds, e.g. --wait-for-browser 120000.\"\n}\n", + "stderr": "", + "exit": 1, + "signal": null + }, + { + "stdout": "{\n \"ok\": false,\n \"error\": \"server_not_running\",\n \"_instructions\": \"No live helper server is recorded for this project. Run the live boot first ( live), open the app URL that serves a pageFiles entry, then rerun this command.\"\n}\n", + "stderr": "", + "exit": 1, + "signal": null + } + ], + "files": { + ".impeccable/live/config.json": "{\n \"files\": [\"index.html\", \"public/**/*.html\"],\n \"insertBefore\": \"\",\n \"commentSyntax\": \"html\"\n}\n" + } +} diff --git a/tests/oracle/golden/live-generate-no-browser-connected.json b/tests/oracle/golden/live-generate-no-browser-connected.json new file mode 100644 index 000000000..e4de5fee2 --- /dev/null +++ b/tests/oracle/golden/live-generate-no-browser-connected.json @@ -0,0 +1,32 @@ +{ + "steps": [ + { + "stdout": "", + "stderr": "", + "exit": null, + "signal": null, + "daemon": true + }, + { + "stdout": "{\n \"ok\": false,\n \"error\": \"no_browser_connected\",\n \"_instructions\": \"No page with the live overlay is connected. Open the app URL that serves a pageFiles entry yourself with your harness browser tool, then rerun this command. Only when no browser tool exists: give the user the URL and rerun with --wait-for-browser 120000 so the command fires as soon as they open the page.\"\n}\n", + "stderr": "", + "exit": 1, + "signal": null + }, + { + "stdout": "Stopped live server on port .\n", + "stderr": "", + "exit": 0, + "signal": null + } + ], + "files": { + ".impeccable/live/config.json": "{\n \"files\": [\"index.html\", \"public/**/*.html\"],\n \"insertBefore\": \"\",\n \"commentSyntax\": \"html\"\n}\n" + }, + "daemon": [ + { + "stdout": "\nImpeccable live server running on http://localhost:\nToken: \n\nScript: http://localhost:/live.js\nInject: managed by impeccable live-inject; Astro source tags use is:inline automatically.\nStop: impeccable live-server stop\n", + "stderr": "" + } + ] +} diff --git a/tests/oracle/golden/pin-bad-command-teach.json b/tests/oracle/golden/pin-bad-command-teach.json index 100a0aec1..b610950e3 100644 --- a/tests/oracle/golden/pin-bad-command-teach.json +++ b/tests/oracle/golden/pin-bad-command-teach.json @@ -1,6 +1,6 @@ { "stdout": "", - "stderr": "Unknown command: teach\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize\n", + "stderr": "Unknown command: teach\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize, generate\n", "exit": 1, "signal": null, "files": {} diff --git a/tests/oracle/golden/pin-bad-command.json b/tests/oracle/golden/pin-bad-command.json index 5d55fe874..fae4186e2 100644 --- a/tests/oracle/golden/pin-bad-command.json +++ b/tests/oracle/golden/pin-bad-command.json @@ -1,6 +1,6 @@ { "stdout": "", - "stderr": "Unknown command: doctor\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize\n", + "stderr": "Unknown command: doctor\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize, generate\n", "exit": 1, "signal": null, "files": {} diff --git a/tests/oracle/golden/pin-usage-no-args.json b/tests/oracle/golden/pin-usage-no-args.json index fe3405569..41647ba3c 100644 --- a/tests/oracle/golden/pin-usage-no-args.json +++ b/tests/oracle/golden/pin-usage-no-args.json @@ -1,5 +1,5 @@ { - "stdout": "Usage: impeccable pin \n\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize\n", + "stdout": "Usage: impeccable pin \n\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize, generate\n", "stderr": "", "exit": 1, "signal": null, diff --git a/tests/oracle/golden/pin-usage-one-arg.json b/tests/oracle/golden/pin-usage-one-arg.json index fe3405569..41647ba3c 100644 --- a/tests/oracle/golden/pin-usage-one-arg.json +++ b/tests/oracle/golden/pin-usage-one-arg.json @@ -1,5 +1,5 @@ { - "stdout": "Usage: impeccable pin \n\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize\n", + "stdout": "Usage: impeccable pin \n\nAvailable commands: craft, init, extract, document, shape, critique, audit, polish, bolder, quieter, distill, harden, onboard, live, animate, colorize, typeset, layout, delight, overdrive, clarify, adapt, optimize, generate\n", "stderr": "", "exit": 1, "signal": null, diff --git a/tests/skill-behavior/scenarios.test.mjs b/tests/skill-behavior/scenarios.test.mjs index c33583611..9d12ef264 100644 --- a/tests/skill-behavior/scenarios.test.mjs +++ b/tests/skill-behavior/scenarios.test.mjs @@ -31,6 +31,7 @@ import { import { detectProvider, getModel, hasKey, resolveModelList, PROVIDERS } from './providers.mjs'; import { assertLauncherDenialWarningBeforeNextTool, assertPlanningFallbackWarning, LAUNCHER_FAILURE_WARNING, assertAdviceOnly, assertWorkflowAdvice, assertCommandComparison, missingReferences } from './assertions.mjs'; import { assertCompleted } from '../skill-workflow/assertions.mjs'; +import { findEngineBinary } from '../lib/engine-bin.mjs'; import { PRODUCT_MD_SAMPLE, PRODUCT_MD_SAMPLE_NO_REGISTER, @@ -106,10 +107,16 @@ function loadedBefore(trace, first, second) { */ function stopLiveHelper(workspace) { try { + const engineBin = findEngineBinary(); execFileSync( - process.execPath, - [path.join(workspace, '.claude/skills/impeccable/scripts/live-server.mjs'), 'stop'], - { cwd: workspace, stdio: 'ignore', timeout: 10_000 }, + path.join(workspace, '.claude/skills/impeccable/scripts/impeccable'), + ['live-server', 'stop'], + { + cwd: workspace, + stdio: 'ignore', + timeout: 10_000, + env: { ...process.env, ...(engineBin ? { IMPECCABLE_BIN: engineBin } : {}) }, + }, ); } catch { /* nothing was running */ } } From da403a341080e622592d856b82a7b2b418e4e809 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Sat, 5 Sep 2026 11:42:58 +0500 Subject: [PATCH 06/42] Address review: the overlay, not the connection, is the roll-call participant An EventSource reconnect opens a replacement connection under the same page-level clientId before the old connection is seen to close, so the close handler used to retire the reconnected overlay's report and hand its lease back mid-flight. remove_sse_client now retires a client's word only when no other connection still carries its id, the roll call counts distinct overlays (plus id-less connections) instead of raw connections, and the overlay ignores a replayed target it already handled, so a reconnect never starts a second claim or a second Go. Covered by two new HTTP cases in crates/cli/tests/agent_target.rs, a protocol case in tests/live-agent-target.test.mjs, and the overlay contract suite. AI-assisted: implemented and tested with Claude Code under maintainer direction. Co-Authored-By: Claude Fable 5 --- crates/cli/tests/agent_target.rs | 45 ++++++++++++++++++++++++++++++ crates/live/src/server_state.rs | 35 +++++++++++++++++++++-- docs/CLI-CONTRACT.md | 2 +- skill/scripts/live-browser.js | 9 ++++++ tests/live-agent-target.test.mjs | 43 ++++++++++++++++++++++++++++ tests/live-browser-source.test.mjs | 5 ++++ 6 files changed, 135 insertions(+), 4 deletions(-) diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs index 0248a2ef9..647d8645a 100644 --- a/crates/cli/tests/agent_target.rs +++ b/crates/cli/tests/agent_target.rs @@ -331,3 +331,48 @@ fn agent_target_replays_pending_targets_to_a_late_overlay() { let (_, verdict) = held.join().unwrap(); assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd")); } + +#[test] +fn agent_target_reconnect_keeps_the_overlays_lease_and_word() { + let s = Server::start("reconnect"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + let mut b = Overlay::connect(s.port, &s.token, "tab-b"); + a.next(|m| m["type"] == "connected"); + b.next(|m| m["type"] == "connected"); + let held = s.hold(serde_json::json!({})); + let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string(); + assert_eq!(s.claim(&target_id, "tab-a", true)["granted"], serde_json::json!(true)); + // An EventSource reconnect: the same page opens a replacement connection + // under its clientId before the old one is seen to close. + let mut a2 = Overlay::connect(s.port, &s.token, "tab-a"); + a2.next(|m| m["type"] == "agent_target"); + drop(a); + std::thread::sleep(Duration::from_millis(150)); + // The old connection's close must not hand tab-a's lease to anyone: + // tab-b stays denied, tab-a renews as the holder. + assert_eq!(s.claim(&target_id, "tab-b", true)["granted"], serde_json::json!(false), "the lease survived the reconnect"); + assert_eq!(s.claim(&target_id, "tab-a", true)["granted"], serde_json::json!(true)); + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccdd" })); + let (_, verdict) = held.join().unwrap(); + assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd")); + let _ = (&mut a2, &mut b); +} + +#[test] +fn agent_target_roll_call_counts_overlays_not_connections() { + let s = Server::start("distinct"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + let mut a2 = Overlay::connect(s.port, &s.token, "tab-a"); + a.next(|m| m["type"] == "connected"); + a2.next(|m| m["type"] == "connected"); + let started = Instant::now(); + let held = s.hold(serde_json::json!({})); + let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string(); + // One overlay behind two connections reports busy once: that completes + // the roll call instead of waiting on a "second" report until timeout. + assert_eq!(s.claim(&target_id, "tab-a", false), serde_json::json!({ "ok": true, "granted": false })); + let (_, verdict) = held.join().unwrap(); + assert_eq!(verdict["error"], serde_json::json!("busy")); + assert!(started.elapsed() < Duration::from_millis(350), "the busy verdict did not wait for the timeout"); + let _ = &mut a2; +} diff --git a/crates/live/src/server_state.rs b/crates/live/src/server_state.rs index c742ce5ef..f2e48d6c9 100644 --- a/crates/live/src/server_state.rs +++ b/crates/live/src/server_state.rs @@ -657,7 +657,12 @@ impl ServerState { /// Remove an SSE client; when none remain arm the exit timer (JS /// `req.on('close')`). A departed overlay's word no longer counts in any - /// agent-target roll call. + /// agent-target roll call. The overlay, not the connection, is the + /// participant: an EventSource reconnect opens a replacement connection + /// under the same page-level clientId before the old one is seen to + /// close, so its word is retired only once no connection carries that + /// id, while every roll call is still re-judged against the connections + /// that remain. pub fn remove_sse_client(&mut self, id: u64) { let before = self.sse_clients.len(); let agent_client_id = self @@ -667,7 +672,11 @@ impl ServerState { .and_then(|c| c.agent_client_id.clone()); self.sse_clients.retain(|c| c.id != id); if before != self.sse_clients.len() { - self.drop_agent_target_client(agent_client_id.as_deref()); + let still_connected = agent_client_id + .as_deref() + .map(|cid| self.sse_clients.iter().any(|c| c.agent_client_id.as_deref() == Some(cid))) + .unwrap_or(false); + self.drop_agent_target_client(if still_connected { None } else { agent_client_id.as_deref() }); if self.sse_clients.is_empty() { self.clear_exit_timer(); self.arm_exit_timer(); @@ -675,6 +684,26 @@ impl ServerState { } } + /// Connected overlays for a roll call: one per distinct clientId, plus + /// every connection that sent none (an older overlay build), so a + /// reconnect's momentary duplicate connection never waits on a second + /// report from the same tab. + pub fn connected_overlay_count(&self) -> usize { + let mut ids: Vec<&str> = Vec::new(); + let mut anonymous = 0; + for c in &self.sse_clients { + match c.agent_client_id.as_deref() { + Some(cid) => { + if !ids.contains(&cid) { + ids.push(cid); + } + } + None => anonymous += 1, + } + } + ids.len() + anonymous + } + // --------------------------------------------------------------------- // Agent-initiated element targeting (the `generate` command) // --------------------------------------------------------------------- @@ -777,7 +806,7 @@ impl ServerState { /// timeout. Judged against the connections of this moment, so it runs /// whenever a report lands and whenever an overlay leaves. pub fn maybe_complete_agent_target_roll_call(&mut self, target_id: &str) { - let connected = self.sse_clients.len(); + let connected = self.connected_overlay_count(); let verdict = self .pending_agent_targets .iter() diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index e90927079..0889331c9 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1468,7 +1468,7 @@ Binds `127.0.0.1:PORT`. CORS: if request has `Origin` and (origin is loopback ht | `GET /design-system.json?token=` | 401 `Unauthorized` | 404 `{present:false}` if neither DESIGN.md nor `.impeccable/design.json`; else `{present:true, hasMd, hasSidecar, mdNewerThanJson, parsed?, parseError?, sidecar?, sidecarError?}` (`parsed` = parseDesignMd output; `sidecarError` = `'Failed to parse .impeccable/design.json: '+msg`) | | `GET /design-system/raw?token=` | 401 | 200 `text/markdown; charset=utf-8` DESIGN.md verbatim; 404 `Not found` | | `GET /source?token=&path=` | 401 | path required and no `..` else 400 `Bad path`; resolved must be inside cwd (relative check, not root itself) else 403 `Forbidden`; 404 `File not found`; 200 `text/html; charset=utf-8` raw file. Used by browser to read source, svelte manifest and `params.json`. | -| `GET /events?token=&clientId=` (SSE) | 401 | `clientId` (optional) is the overlay's per-page-load id; on close the server retires that client's agent-target roll-call report and releases a lease it held, then re-judges each pending roll call against the remaining clients. Headers `text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`; first frame `data: {"type":"connected","hasProjectContext":b,"agentPolling":b,"activeSessions":[…]}\n\n`; `: keepalive\n\n` every 30s; on connect: cancels exit timer and removes queued anonymous `exit` events. On close: if 0 clients, after 8000 ms (still 0) enqueue `{type:'exit'}`. | +| `GET /events?token=&clientId=` (SSE) | 401 | `clientId` (optional) is the overlay's per-page-load id; on close the server retires that client's agent-target roll-call report and releases a lease it held **only when no other connection still carries that id** (an EventSource reconnect opens the replacement before the old connection is seen to close), then re-judges each pending roll call against the remaining overlays (distinct ids, plus connections that sent none). The pending targets are replayed to every connection that opens; the overlay ignores a replay of a target it already handled. Headers `text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`; first frame `data: {"type":"connected","hasProjectContext":b,"agentPolling":b,"activeSessions":[…]}\n\n`; `: keepalive\n\n` every 30s; on connect: cancels exit timer and removes queued anonymous `exit` events. On close: if 0 clients, after 8000 ms (still 0) enqueue `{type:'exit'}`. | | `POST /events` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | see 6.1 | | `GET /stop?token=` | 401 | 200 text `stopping`, then shutdown | | `GET /poll?token=&timeout=&leaseMs=&types=` | 401 `{"error":"Unauthorized"}` | see 6.3 | diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index e93a8bba9..9d6a3d1ba 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -7278,8 +7278,17 @@ } } + // Targets this page already answered (claimed, declined, or acted on). + // The server replays pending targets to every connection that opens, and + // an EventSource reconnect opens one for a page that already heard the + // target, so a replay must not start a second claim or a second Go. + const agentTargetsSeen = []; + function handleAgentTarget(msg) { if (!msg || typeof msg.targetId !== 'string') return; + if (agentTargetsSeen.includes(msg.targetId)) return; + agentTargetsSeen.push(msg.targetId); + if (agentTargetsSeen.length > 100) agentTargetsSeen.shift(); const busy = agentTargetBusyReason(); if (busy) { // Roll call: a busy tab reports itself and never acts. The server diff --git a/tests/live-agent-target.test.mjs b/tests/live-agent-target.test.mjs index 6d0ffcaca..320cf1538 100644 --- a/tests/live-agent-target.test.mjs +++ b/tests/live-agent-target.test.mjs @@ -598,6 +598,49 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA } }); + it('keeps a reconnected overlay\'s lease and word when its old connection closes', async () => { + // An EventSource reconnect opens a replacement connection under the same + // page-level clientId before the old one is seen to close. The close + // must retire nothing while the overlay is still connected. + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + const tabA = await openSseClient(server, { clientId: 'tab-a' }); + const tabB = await openSseClient(server, { clientId: 'tab-b' }); + let tabA2 = null; + try { + await tabA.next((m) => m.type === 'connected'); + await tabB.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tabA.next((m) => m.type === 'agent_target'); + const holder = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, + })).json(); + assert.equal(holder.granted, true); + tabA2 = await openSseClient(server, { clientId: 'tab-a' }); + await tabA2.next((m) => m.type === 'agent_target'); + tabA.close(); + await sleep(150); + const rival = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true, + })).json(); + assert.equal(rival.granted, false, 'the lease survived the reconnect'); + const renew = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, + })).json(); + assert.equal(renew.granted, true, 'the reconnected overlay still holds it'); + await postJson(server, '/agent-target-result', { + token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + }); + const verdict = await (await held).json(); + assert.equal(verdict.sessionId, 'aabbccdd'); + } finally { + tabA.close(); + tabB.close(); + if (tabA2) tabA2.close(); + } + }); + it('completes the roll call when the last silent overlay disconnects', async () => { // Tab A reported busy; tab B never answered and then left. Every overlay // still connected has declined, so the verdict is busy now, not at the diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index 66c82868c..b2958d27d 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -846,6 +846,11 @@ describe('live-browser source contracts', () => { /\/events\?token=' \+ TOKEN \+ '&clientId=' \+ AGENT_TARGET_CLIENT_ID/, 'the SSE connection must carry the overlay id, so a disconnect retires its roll-call word', ); + assert.match( + SOURCE, + /function handleAgentTarget\(msg\) \{[\s\S]{0,120}?if \(agentTargetsSeen\.includes\(msg\.targetId\)\) return;/, + 'a replayed target this page already handled must not start a second claim or Go', + ); assert.match(helper, /setTimeout\(\(\) => claimAndActOnAgentTarget\(msg\), AGENT_TARGET_RESCUE_RETRY_MS\);/, 'a denied claim on a live request retries until the lease lapses'); assert.match( SOURCE, From 8fb7f7fec56b8b8e129b7033641917783d74fead Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Wed, 9 Sep 2026 20:31:23 +0500 Subject: [PATCH 07/42] Hook: stand down for the whole edit when the primary carries live markers Field-testing the generate command on a Vite React app showed the per-file stand-down was not enough: an edit to the wrapped App.jsx skipped that file but still co-scanned the stylesheet it imports and spoke up about it (a clean ack or findings) mid-session, which is exactly the noise the stand-down exists to prevent. When the edited primary file carries the markers, the whole PostToolUse event now returns skipped: live-preview with the audit naming that primary, co-scanned stylesheets included; a marked file that is only co-scanned still skips alone. A unit case covers both, and the contract documents the event-level stand-down. AI-assisted: found by field tests and fixed with Claude Code under maintainer direction. Co-Authored-By: Claude Fable 5 --- crates/hook/src/hook.rs | 20 ++++++++++++++++++++ crates/hook/tests/hook_tests.rs | 23 +++++++++++++++++++++++ docs/CLI-CONTRACT.md | 2 +- 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/hook/src/hook.rs b/crates/hook/src/hook.rs index 291567cac..da0e3d655 100644 --- a/crates/hook/src/hook.rs +++ b/crates/hook/src/hook.rs @@ -185,6 +185,7 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult { let quiet_mode = truthy(rt.env("IMPECCABLE_HOOK_QUIET")) || config.quiet; let mut detector_threw_any = false; let mut last_skip = "no-scannable-file"; + let mut live_preview_edit: Option = None; let mut suppressed_hit = false; let mut cache_dirty = false; let mut deferred_total: usize = 0; @@ -277,6 +278,14 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult { } }; if crate::hook_lib::has_live_preview_markers(&content) { + // A live variant session owns this file. When it is the edited + // (primary) file, the whole event stands down, co-scanned + // stylesheets included: a clean ack or a finding about the + // companion file is the same mid-session noise the stand-down + // exists to prevent. + if primary_files.contains(file_path) && live_preview_edit.is_none() { + live_preview_edit = Some(file_path.clone()); + } last_skip = "live-preview"; continue; } @@ -358,6 +367,17 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult { } } } + if let Some(file) = live_preview_edit { + audit.insert("file".into(), Value::String(file)); + return result( + &audit, + vec![ + ("emitted", Value::Bool(false)), + ("skipped", Value::from("live-preview")), + ("durationMs", ms_since(started)), + ], + ); + } if !fresh_groups.is_empty() { let scan = &scans[&fresh_groups[0].file_path]; diff --git a/crates/hook/tests/hook_tests.rs b/crates/hook/tests/hook_tests.rs index e8e470478..af61f9559 100644 --- a/crates/hook/tests/hook_tests.rs +++ b/crates/hook/tests/hook_tests.rs @@ -2258,3 +2258,26 @@ fn before_edit_stands_down_on_live_preview_markers() { assert_eq!(code, 0); assert_eq!(out, "{\"permission\":\"allow\"}"); } + +#[test] +fn run_hook_stands_down_for_the_whole_edit_when_the_primary_carries_live_markers() { + // The edited JSX carries the wrapper; the stylesheet it imports does not. + // Co-scanning would still speak up about the stylesheet mid-session, so + // the whole event stands down. The same files without markers prove the + // co-scan is otherwise live. + let t = Tmp::new(); + let cwd = t.path(); + let r = rt(&cwd); + t.write("src/styles.css", GRADIENT_CSS); + let plain = t.write("src/Plain.jsx", "import './styles.css';\nexport default function Plain() { return

Hi

; }\n"); + let reported = hook::run_hook(&r, &edit_event(&cwd, &plain, "s1")); + assert!(reported.stdout.contains("[gradient-text]"), "co-scanned stylesheet is reported without markers: {}", reported.stdout); + let wrapped = t.write( + "src/App.jsx", + "import './styles.css';\n{/* impeccable-variants-start ab12cd34 */}
\n", + ); + let skipped = hook::run_hook(&r, &edit_event(&cwd, &wrapped, "s2")); + assert_eq!(skipped.stdout, "", "nothing is emitted while the edited file is in a live session"); + assert_eq!(skipped.audit["skipped"], json!("live-preview")); + assert!(audit_str(&skipped.audit, "file").unwrap_or("").ends_with("src/App.jsx"), "the audit names the edited file, not the companion"); +} diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 0889331c9..3b29b1041 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1105,7 +1105,7 @@ Candidates in order: `/detector/detect-antipatterns.mjs` (built skill l 4. `config = readConfig(projectCwd)`; `enabled === false` → `'config-disabled'`. 5. native platform → `skipped:'native-platform', platform`. 6. `cache = readCache(projectCwd)`; `sessionId = event.session_id || 'unknown'`; detector missing → `'detector-missing'`; `scanOptions = designSystemOptions(...)`; `tiered = perEditTieringActive(config, harness)`; `quietMode = truthy(IMPECCABLE_HOOK_QUIET) || config.quiet`. - 7. For each target file (audit.file updated each iteration): skip with `lastSkip` = `'sensitive'` (contains `..` or SENSITIVE_PATH), `'generated'`, `'extension'` (not ALLOWED and not configured), `'config-ignore-file'` (`matchesAnyGlob(relativized)` or `(absolute)` vs `config.ignoreFiles`), `'file-missing'`, `'outside-project'`, `'too-large'` (records `skippedBytes`), and, once the content is read, `'live-preview'` (LIVE_PREVIEW_MARKERS). If the file is a PRIMARY (not co-scanned): `editCount = bumpEditCount(...)`; if `editCount > 6` → if `=== 7` and no suppression winner yet → `suppressionWinner={filePath}`; `lastSkip='suppressed'`, `suppressedHit=true`, continue. Read content, run detector (throw → `findings=[]`, `detectorThrew=true`). `filtered = filterFindings(...)`; if tiered split into immediate/deferred else all immediate. If deferred non-empty → `touchFile`, `deferredTotal += n`. `fresh = dedupeAgainstCache(immediate, ...)`. `audit.findings = raw count`, `audit.freshFindings = fresh.length`, `audit.deferred = deferredTotal` (if >0). If detectorThrew → `detectorThrewAny=true`, continue (cache untouched for that file). `rememberFindings(cache, sid, file, immediate)` (replace). If fresh>0 → push `{filePath, findings: fresh}` to `freshGroups`, continue. Else if immediate>0 and no pendingWinner → `pendingWinner={filePath, known: immediate.map(findingCacheKey)}`; else if immediate==0 and no cleanWinner: if quiet or not ack-eligible → `cleanWinner={filePath}` (without consuming `cleanAcked`); else if `fileEntry.cleanAcked` → `cleanAckDeduped=true` (keep scanning); else set `cleanAcked=true`, `cleanWinner={filePath}`, `cleanAckDeduped=false`. + 7. For each target file (audit.file updated each iteration): skip with `lastSkip` = `'sensitive'` (contains `..` or SENSITIVE_PATH), `'generated'`, `'extension'` (not ALLOWED and not configured), `'config-ignore-file'` (`matchesAnyGlob(relativized)` or `(absolute)` vs `config.ignoreFiles`), `'file-missing'`, `'outside-project'`, `'too-large'` (records `skippedBytes`), and, once the content is read, `'live-preview'` (LIVE_PREVIEW_MARKERS; when the marked file is the edited PRIMARY, the whole event returns `{emitted:false, skipped:'live-preview'}` with `file` = that primary, co-scanned stylesheets included). If the file is a PRIMARY (not co-scanned): `editCount = bumpEditCount(...)`; if `editCount > 6` → if `=== 7` and no suppression winner yet → `suppressionWinner={filePath}`; `lastSkip='suppressed'`, `suppressedHit=true`, continue. Read content, run detector (throw → `findings=[]`, `detectorThrew=true`). `filtered = filterFindings(...)`; if tiered split into immediate/deferred else all immediate. If deferred non-empty → `touchFile`, `deferredTotal += n`. `fresh = dedupeAgainstCache(immediate, ...)`. `audit.findings = raw count`, `audit.freshFindings = fresh.length`, `audit.deferred = deferredTotal` (if >0). If detectorThrew → `detectorThrewAny=true`, continue (cache untouched for that file). `rememberFindings(cache, sid, file, immediate)` (replace). If fresh>0 → push `{filePath, findings: fresh}` to `freshGroups`, continue. Else if immediate>0 and no pendingWinner → `pendingWinner={filePath, known: immediate.map(findingCacheKey)}`; else if immediate==0 and no cleanWinner: if quiet or not ack-eligible → `cleanWinner={filePath}` (without consuming `cleanAcked`); else if `fileEntry.cleanAcked` → `cleanAckDeduped=true` (keep scanning); else set `cleanAcked=true`, `cleanWinner={filePath}`, `cleanAckDeduped=false`. 8. If `freshGroups` non-empty: `text = appendDesignSystemNoteOnce(renderGroupedTemplate(freshGroups, config, {cwd:projectCwd, footer: footerModeForSession, reserveChars: designNoteReserve}), ...)`; `commitFooterShown`; **`persistCache` always** (creates `.impeccable/` if needed); return `stdout = payload(text,'PostToolUse',harness)`, audit `{..., file: firstGroup.filePath, emitted:true, freshFiles, freshFindings(total), chars, durationMs}`, `emission:{kind:'fresh', file, findings, groups}`. 9. Else compute `ack`: not quiet AND pendingWinner AND ack-eligible → `{kind:'pending', text: appendDesignSystemNoteOnce(renderPendingAck(...))}`; else not quiet AND no suppressionWinner AND cleanWinner AND !cleanAckDeduped AND ack-eligible → `{kind:'clean', text: appendDesignSystemNoteOnce(renderCleanAck(...))}`. 10. Persist cache only if `deferredTotal > 0 || (cacheDirty && exists(/.impeccable))` (a clean edit in a project with no `.impeccable/` footprint writes nothing to disk). From 25263adc5146f9d813ebc5a1547cb083f744ae8c Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Wed, 9 Sep 2026 20:39:30 +0500 Subject: [PATCH 08/42] Roll call: a page that cannot resolve the target declines instead of claiming Field-testing with two pages open showed the first-wins claim letting the wrong page answer: a tab whose page lacks the element won the claim, resolved the selector locally, and replied no_match while another page had the element. The overlay now resolves the selector before any claim and, when its page cannot resolve it, declines with reason no_match and the resolution verdict; the same check runs on the busy-to-idle re-claim. The server records that verdict on the report and, once every connected overlay has declined, prefers a report that could serve later (a tab mid-session or with an apply in flight, which answers busy so the agent retries) over no_match, and returns the resolution verdict only when no page can serve; the timeout uses the same precedence. Also from the same field tests: a tab on another page of the app was resuming this page's session from the per-origin localStorage cache after a dev-server reload re-initialised it, then sat in GENERATING for a wrapper it never renders and declined every later target. restoreSessionWithoutWrapper now resumes a cached session only on the page that saved it, the check the server-adoption branch beside it already applied. Covered by two Rust integration cases, two protocol cases, and contract assertions for the resolve-before-claim path and the page gate; the cross-page scenario of the field harness passes on a two-page site. AI-assisted: found by field tests and fixed with Claude Code under maintainer direction. Co-Authored-By: Claude Fable 5 --- crates/cli/tests/agent_target.rs | 42 ++++++++++++++++++++++ crates/live/src/live_server.rs | 3 +- crates/live/src/server_state.rs | 32 ++++++++++++++--- docs/CLI-CONTRACT.md | 2 +- skill/scripts/live-browser.js | 22 +++++++++++- tests/live-agent-target.test.mjs | 57 ++++++++++++++++++++++++++++++ tests/live-browser-source.test.mjs | 26 ++++++++++++++ 7 files changed, 177 insertions(+), 7 deletions(-) diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs index 647d8645a..0f054b95c 100644 --- a/crates/cli/tests/agent_target.rs +++ b/crates/cli/tests/agent_target.rs @@ -376,3 +376,45 @@ fn agent_target_roll_call_counts_overlays_not_connections() { assert!(started.elapsed() < Duration::from_millis(350), "the busy verdict did not wait for the timeout"); let _ = &mut a2; } + +#[test] +fn agent_target_answers_the_resolution_verdict_when_no_page_can_serve() { + let s = Server::start("no-match"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + let mut b = Overlay::connect(s.port, &s.token, "tab-b"); + a.next(|m| m["type"] == "connected"); + b.next(|m| m["type"] == "connected"); + let started = Instant::now(); + let held = s.hold(serde_json::json!({})); + let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string(); + // Both idle pages lack the element: each declines with its resolution + // verdict instead of claiming. + let decline = |cid: &str, raw: u64| serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": cid, "eligible": false, "state": "IDLE", "reason": "no_match", "result": { "ok": false, "error": "no_match", "selector": "h1", "matchCount": 0, "rawMatchCount": raw } }); + assert_eq!(post_json(s.port, "/agent-target-claim", decline("tab-a", 0)).1, serde_json::json!({ "ok": true, "granted": false })); + assert_eq!(post_json(s.port, "/agent-target-claim", decline("tab-b", 2)).1, serde_json::json!({ "ok": true, "granted": false })); + let (_, verdict) = held.join().unwrap(); + assert_eq!(verdict["error"], serde_json::json!("no_match"), "{verdict}"); + assert_eq!(verdict["ok"], serde_json::json!(false)); + assert_eq!(verdict["targetId"], serde_json::json!(target_id)); + assert!(started.elapsed() < Duration::from_millis(350), "answered by the roll call, not the timeout"); + let _ = &mut b; +} + +#[test] +fn agent_target_prefers_busy_over_no_match_across_pages() { + let s = Server::start("busy-wins"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + let mut b = Overlay::connect(s.port, &s.token, "tab-b"); + a.next(|m| m["type"] == "connected"); + b.next(|m| m["type"] == "connected"); + let held = s.hold(serde_json::json!({})); + let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string(); + // The page that has the element is mid-session; the other page lacks it. + // The agent should retry later, so busy outranks no_match. + post_json(s.port, "/agent-target-claim", serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-b", "eligible": false, "state": "IDLE", "reason": "no_match", "result": { "ok": false, "error": "no_match", "matchCount": 0, "rawMatchCount": 0 } })); + assert_eq!(s.claim(&target_id, "tab-a", false), serde_json::json!({ "ok": true, "granted": false })); + let (_, verdict) = held.join().unwrap(); + assert_eq!(verdict["error"], serde_json::json!("busy"), "{verdict}"); + assert_eq!(verdict["reason"], serde_json::json!("session_active")); + let _ = &mut b; +} diff --git a/crates/live/src/live_server.rs b/crates/live/src/live_server.rs index 6b2837ea8..2b4bd7fab 100644 --- a/crates/live/src/live_server.rs +++ b/crates/live/src/live_server.rs @@ -2797,7 +2797,8 @@ fn handle_agent_target_claim_post( let eligible = msg.get("eligible").and_then(Value::as_bool) == Some(true); let state = msg.get("state").cloned().unwrap_or(Value::Null); let reason = msg.get("reason").cloned().unwrap_or(Value::Null); - let body = lock(shared).claim_agent_target(&target_id, &client_id, eligible, state, reason); + let result = msg.get("result").filter(|r| r.is_object()).cloned(); + let body = lock(shared).claim_agent_target(&target_id, &client_id, eligible, state, reason, result); respond(stream, cors, json_res(200, body)); } diff --git a/crates/live/src/server_state.rs b/crates/live/src/server_state.rs index f2e48d6c9..1631f7519 100644 --- a/crates/live/src/server_state.rs +++ b/crates/live/src/server_state.rs @@ -49,6 +49,9 @@ pub struct AgentTargetReport { pub client_id: String, pub state: Value, pub reason: Value, + /// The overlay's resolution verdict when it declined because its page + /// cannot resolve the target (`reason: no_match`). + pub result: Option, } /// A held-open `POST /agent-target` (the `generate` command): resolved by @@ -780,7 +783,7 @@ impl ServerState { let verdict = if pending.reports.is_empty() { json!({ "ok": false, "error": "browser_timeout", "timeoutMs": timeout_ms }) } else { - agent_target_busy_verdict(pending) + agent_target_verdict_from_reports(pending) }; st.resolve_agent_target(&id, verdict); } @@ -815,7 +818,7 @@ impl ServerState { if p.owner.is_some() || p.reports.is_empty() || p.reports.len() < connected { None } else { - Some(agent_target_busy_verdict(p)) + Some(agent_target_verdict_from_reports(p)) } }); if let Some(verdict) = verdict { @@ -864,6 +867,7 @@ impl ServerState { eligible: bool, state: Value, reason: Value, + result: Option, ) -> Value { let lease_ms = self.agent_target_lease_ms(); let now = now_i64(); @@ -880,6 +884,7 @@ impl ServerState { client_id: client_id.to_string(), state, reason, + result, }); // A holder that turned busy hands the lease back, so the roll // call can complete and an eligible tab's retry is granted at @@ -1478,8 +1483,27 @@ fn env_positive_ms(env: &Env, key: &str) -> Option { .filter(|v| *v > 0) } -/// The busy verdict for a held target: the first report's state and reason. -pub fn agent_target_busy_verdict(pending: &AgentTargetPending) -> Value { +/// The verdict for a held target once every connected overlay declined. A +/// tab that could serve later (mid-session, an apply in flight) outranks a +/// page that simply lacks the element, so the agent retries instead of +/// giving up; only when no page can resolve the target does the resolution +/// verdict (`no_match`, `invalid_selector`, ...) come back. +pub fn agent_target_verdict_from_reports(pending: &AgentTargetPending) -> Value { + let busy = pending + .reports + .iter() + .find(|r| r.reason.as_str() != Some("no_match")) + .or_else(|| pending.reports.first()); + if let Some(r) = busy.filter(|r| r.reason.as_str() != Some("no_match")) { + return json!({ "ok": false, "error": "busy", "state": r.state, "reason": r.reason }); + } + if let Some(result) = pending.reports.iter().find_map(|r| r.result.as_ref()) { + let mut verdict = result.clone(); + if let Some(obj) = verdict.as_object_mut() { + obj.insert("ok".into(), json!(false)); + } + return verdict; + } let first = pending.reports.first(); json!({ "ok": false, diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 3b29b1041..e902334bd 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1481,7 +1481,7 @@ Binds `127.0.0.1:PORT`. CORS: if request has `Origin` and (origin is loopback ht | `POST /manual-edit` | | 410 `{"error":"/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits."}` | | `POST /agent-target` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | Agent-initiated targeting (the `generate` command). Validation (400 `{"error":}`, messages verbatim): `agent_target: selector is required`, `agent_target: selector too long` (>1000 chars), `agent_target: invalid action (valid: )`, `agent_target: count must be 1-8`, `agent_target: text must be a string of at most 500 chars`, `agent_target: index must be a positive integer (1-based)`, `agent_target: prompt must be a string of at most 2000 chars`, `agent_target: dryRun must be a boolean`. No SSE client → 200 `{ok:false, error:'no_browser_connected'}`. Otherwise mint an 8-hex `targetId`, broadcast `agent_target` (see 6.2), and **hold the response** until `/agent-target-result` resolves it, every connected overlay has declined (busy roll call, see `/agent-target-claim`), or `IMPECCABLE_AGENT_TARGET_TIMEOUT_MS` (default 15000) elapses: busy verdict `{ok:false, error:'busy', state, reason}` from the first report when any report exists, else `{ok:false, error:'browser_timeout', timeoutMs}`. The held reply is 200 `{targetId, ...result}`; shutdown resolves every held request with `{ok:false, error:'server_stopping'}`. | | `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). | -| `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason}` under `clientId` (replacing an earlier report), release the lease if this client holds it, answer `{ok:true, granted:false}`, then complete the roll call when no owner holds the lease and reports ≥ connected SSE clients (verdict from the first report). `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | +| `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason, result?}` under `clientId` (replacing an earlier report; `result` is the overlay's resolution verdict when `reason` is `no_match`, i.e. its page cannot resolve the selector), release the lease if this client holds it, answer `{ok:true, granted:false}`, then complete the roll call when no owner holds the lease and reports ≥ connected overlays. Verdict precedence: a report whose `reason` is not `no_match` (a tab that could serve later) → `{ok:false, error:'busy', state, reason}`; otherwise the first report's `result` (e.g. `no_match` with `rawMatchCount`, `invalid_selector`); the timeout uses the same precedence when any report exists. `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | | anything else | | 404 `Not found` | Pending-event summary in `/status.pendingEvents[]`: `{id, type, leased:boolean, leaseUntil:number|null}` plus for `manual_edit_apply`: `pageUrl, chunk, repair, evidencePath, agentAction, manualApplySummary:{pageUrl, chunk, entryCount, opCount, files[]}`. diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 9d6a3d1ba..055dafa0c 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -7263,6 +7263,7 @@ if (agentTargetOverlayGone()) return; const busy = agentTargetBusyReason(); if (busy) { declineAgentTargetBusy(msg, busy); return; } + if (declineAgentTargetUnresolvable(msg)) return; claimAgentTarget(msg.targetId, { eligible: true }).then((claim) => { if (claim.granted) { actOnAgentTarget(msg); return; } if (!claim.pending) return; @@ -7284,6 +7285,19 @@ // target, so a replay must not start a second claim or a second Go. const agentTargetsSeen = []; + // Only a page that can resolve the target claims it. A tab whose page + // lacks the element declines with its resolution verdict instead, so a + // first-wins claim never lets the wrong page answer for a target that + // another page has. The server prefers a busy report (a tab that could + // serve later) over these, and returns the resolution verdict only when + // no connected page can serve. + function declineAgentTargetUnresolvable(msg) { + const probe = resolveAgentTargetElement(msg); + if (!probe.error) return false; + claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: probe.error }); + return true; + } + function handleAgentTarget(msg) { if (!msg || typeof msg.targetId !== 'string') return; if (agentTargetsSeen.includes(msg.targetId)) return; @@ -7297,6 +7311,7 @@ declineAgentTargetBusy(msg, busy); return; } + if (declineAgentTargetUnresolvable(msg)) return; // Eligible tabs race for the server's lease and only the holder acts. A // hidden tab yields a short head start so a visible one wins when both // exist, and still serves the request on its own: the user finds the @@ -9376,7 +9391,12 @@ void main() { } function restoreSessionWithoutWrapper(reason, activeSessions) { - const cached = loadSession(); + // The session cache is per origin, so a tab on another page of the same + // app sees this page's session too. Only the page that saved it may + // resume it: the server-adoption branch below already applies the same + // check, and a tab on another page has nothing to render for it. + const cachedRaw = loadSession(); + const cached = cachedRaw?.id && !pageMatchesCurrent(cachedRaw.pageUrl) ? null : cachedRaw; // localStorage is a cache, not a gate. A cleared tab, a second browser // profile, or a teardown that dropped local state all leave the durable // server session as the only record of work in progress; adopt it instead diff --git a/tests/live-agent-target.test.mjs b/tests/live-agent-target.test.mjs index 320cf1538..c19e2da6f 100644 --- a/tests/live-agent-target.test.mjs +++ b/tests/live-agent-target.test.mjs @@ -641,6 +641,63 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA } }); + it('answers the resolution verdict when every idle page declined as unable to resolve', async () => { + // Two idle tabs on pages that lack the element decline with their + // resolution verdicts; the request resolves as no_match, not busy. + const tabA = await openSseClient(server, { clientId: 'tab-a' }); + const tabB = await openSseClient(server, { clientId: 'tab-b' }); + try { + await tabA.next((m) => m.type === 'connected'); + await tabB.next((m) => m.type === 'connected'); + const startedAt = Date.now(); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tabA.next((m) => m.type === 'agent_target'); + for (const [clientId, raw] of [['tab-a', 0], ['tab-b', 3]]) { + const report = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId, eligible: false, state: 'IDLE', reason: 'no_match', + result: { ok: false, error: 'no_match', selector: 'h1', matchCount: 0, rawMatchCount: raw }, + })).json(); + assert.deepEqual(report, { ok: true, granted: false }); + } + const verdict = await (await held).json(); + assert.equal(verdict.error, 'no_match'); + assert.equal(verdict.ok, false); + assert.equal(verdict.targetId, pushed.targetId); + assert.ok(Date.now() - startedAt < 350, 'answered by the roll call, not the timeout'); + } finally { + tabA.close(); + tabB.close(); + } + }); + + it('prefers busy over no_match, so the agent retries when the right page is mid-session', async () => { + const tabA = await openSseClient(server, { clientId: 'tab-a' }); + const tabB = await openSseClient(server, { clientId: 'tab-b' }); + try { + await tabA.next((m) => m.type === 'connected'); + await tabB.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tabA.next((m) => m.type === 'agent_target'); + await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: false, state: 'IDLE', reason: 'no_match', + result: { ok: false, error: 'no_match', matchCount: 0, rawMatchCount: 0 }, + }); + await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: false, state: 'CYCLING', reason: 'session_active', + }); + const verdict = await (await held).json(); + assert.equal(verdict.error, 'busy'); + assert.equal(verdict.reason, 'session_active'); + } finally { + tabA.close(); + tabB.close(); + } + }); + it('completes the roll call when the last silent overlay disconnects', async () => { // Tab A reported busy; tab B never answered and then left. Every overlay // still connected has declined, so the verdict is busy now, not at the diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index b2958d27d..2338a862f 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -851,6 +851,32 @@ describe('live-browser source contracts', () => { /function handleAgentTarget\(msg\) \{[\s\S]{0,120}?if \(agentTargetsSeen\.includes\(msg\.targetId\)\) return;/, 'a replayed target this page already handled must not start a second claim or Go', ); + // A page that cannot resolve the target never claims it: a first-wins + // claim would otherwise let the wrong page answer no_match for a target + // another page has. + assert.match( + SOURCE, + /function handleAgentTarget\(msg\) \{[\s\S]{0,700}?if \(declineAgentTargetUnresolvable\(msg\)\) return;/, + 'the first claim resolves the selector on this page first', + ); + assert.match( + SOURCE, + /function claimAndActOnAgentTarget\(msg\) \{[\s\S]{0,300}?if \(declineAgentTargetUnresolvable\(msg\)\) return;/, + 'the re-claim resolves the selector on this page first', + ); + assert.match( + SOURCE, + /function declineAgentTargetUnresolvable\(msg\) \{[\s\S]{0,200}?resolveAgentTargetElement\(msg\)[\s\S]{0,200}?reason: 'no_match', result: probe\.error/, + 'the decline carries the resolution verdict for the server to return when no page can serve', + ); + // The per-origin session cache must not let a tab on another page of + // the app resume this page's session (it would sit in GENERATING for a + // wrapper it never renders, and decline every later agent target). + assert.match( + SOURCE, + /function restoreSessionWithoutWrapper\(reason, activeSessions\) \{[\s\S]{0,600}?const cached = cachedRaw\?\.id && !pageMatchesCurrent\(cachedRaw\.pageUrl\) \? null : cachedRaw;/, + 'a cached session is resumed only by the page that saved it', + ); assert.match(helper, /setTimeout\(\(\) => claimAndActOnAgentTarget\(msg\), AGENT_TARGET_RESCUE_RETRY_MS\);/, 'a denied claim on a live request retries until the lease lapses'); assert.match( SOURCE, From 46f1dd6b3617d440ee381de6981b8bcd499eff51 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Wed, 9 Sep 2026 20:51:55 +0500 Subject: [PATCH 09/42] Address review: re-check a failed resolution before declining an agent target A page's element can be momentarily absent (a route still rendering, an HMR commit mid-swap), so a failed resolution is not that page's final word. The overlay now re-checks at 300, 700, and 1500 ms, claims the moment the element mounts (the server already drops the stale report on an eligible claim), and reports only the last miss. A genuine no_match now takes about two seconds instead of tens of milliseconds, well inside the server's hold. Also normalizes a path separator in the new hook unit test, which failed on rust-windows because the audit's file path carries backslashes there. AI-assisted: implemented and tested with Claude Code under maintainer direction. Co-Authored-By: Claude Fable 5 --- crates/hook/tests/hook_tests.rs | 3 ++- skill/scripts/live-browser.js | 25 ++++++++++++++++++++++++- tests/live-browser-source.test.mjs | 13 +++++++++---- 3 files changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/hook/tests/hook_tests.rs b/crates/hook/tests/hook_tests.rs index af61f9559..1a26c681b 100644 --- a/crates/hook/tests/hook_tests.rs +++ b/crates/hook/tests/hook_tests.rs @@ -2279,5 +2279,6 @@ fn run_hook_stands_down_for_the_whole_edit_when_the_primary_carries_live_markers let skipped = hook::run_hook(&r, &edit_event(&cwd, &wrapped, "s2")); assert_eq!(skipped.stdout, "", "nothing is emitted while the edited file is in a live session"); assert_eq!(skipped.audit["skipped"], json!("live-preview")); - assert!(audit_str(&skipped.audit, "file").unwrap_or("").ends_with("src/App.jsx"), "the audit names the edited file, not the companion"); + let audited = audit_str(&skipped.audit, "file").unwrap_or("").replace('\\', "/"); + assert!(audited.ends_with("src/App.jsx"), "the audit names the edited file, not the companion: {audited}"); } diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 055dafa0c..a12c7ec64 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -7291,13 +7291,36 @@ // another page has. The server prefers a busy report (a tab that could // serve later) over these, and returns the resolution verdict only when // no connected page can serve. + // + // An element can be momentarily absent (a route still rendering, an HMR + // commit mid-swap), so a failed resolution is not this page's final word: + // it is re-checked a few times over about two seconds, claiming the + // moment the element mounts, and only the last miss is reported. The + // server's timeout still bounds the whole exchange. + const AGENT_TARGET_RESOLVE_RETRY_MS = [300, 700, 1500]; + function declineAgentTargetUnresolvable(msg) { const probe = resolveAgentTargetElement(msg); if (!probe.error) return false; - claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: probe.error }); + retryAgentTargetResolution(msg, 0, probe.error); return true; } + function retryAgentTargetResolution(msg, attempt, lastError) { + if (attempt >= AGENT_TARGET_RESOLVE_RETRY_MS.length) { + claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: lastError }); + return; + } + setTimeout(() => { + if (agentTargetOverlayGone()) return; + const busy = agentTargetBusyReason(); + if (busy) { declineAgentTargetBusy(msg, busy); return; } + const probe = resolveAgentTargetElement(msg); + if (!probe.error) { claimAndActOnAgentTarget(msg); return; } + retryAgentTargetResolution(msg, attempt + 1, probe.error); + }, AGENT_TARGET_RESOLVE_RETRY_MS[attempt]); + } + function handleAgentTarget(msg) { if (!msg || typeof msg.targetId !== 'string') return; if (agentTargetsSeen.includes(msg.targetId)) return; diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index 2338a862f..9585e006c 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -866,8 +866,13 @@ describe('live-browser source contracts', () => { ); assert.match( SOURCE, - /function declineAgentTargetUnresolvable\(msg\) \{[\s\S]{0,200}?resolveAgentTargetElement\(msg\)[\s\S]{0,200}?reason: 'no_match', result: probe\.error/, - 'the decline carries the resolution verdict for the server to return when no page can serve', + /function declineAgentTargetUnresolvable\(msg\) \{[\s\S]{0,200}?resolveAgentTargetElement\(msg\)[\s\S]{0,120}?retryAgentTargetResolution\(msg, 0, probe\.error\)/, + 'a failed resolution is re-checked before it becomes this page\'s word', + ); + assert.match( + SOURCE, + /function retryAgentTargetResolution\(msg, attempt, lastError\) \{[\s\S]{0,200}?reason: 'no_match', result: lastError[\s\S]{0,600}?if \(!probe\.error\) \{ claimAndActOnAgentTarget\(msg\); return; \}/, + 'the page claims the moment the element mounts, and reports only the last miss', ); // The per-origin session cache must not let a tab on another page of // the app resume this page's session (it would sit in GENERATING for a @@ -885,8 +890,8 @@ describe('live-browser source contracts', () => { ); assert.equal( (SOURCE.match(/claimAndActOnAgentTarget\(msg\)/g) || []).length, - 4, - 'the first claim and the busy-to-idle re-claim must share the rescue path (definition, two call sites, the retry)', + 5, + 'the first claim, the busy-to-idle re-claim, and the resolution re-check must share the rescue path (definition, three call sites, the retry)', ); }); From bd4ec3a11c4d58edd8d40a4324a42898b9d3437e Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Wed, 9 Sep 2026 21:10:27 +0500 Subject: [PATCH 10/42] Address review: the server ends the resolution watch, and a reconnect re-participates Two ways a page's word could go stale after the resolve-before-claim change: an element that mounts later than the quick re-checks, and an EventSource reconnect that did not overlap the old connection (the server drops that page's word on the close, replays the target, and the replay guard ignored it, so the roll call waited on a word that never came). A decline's answer now carries pending, like a denied claim does, so a page that could not resolve the target reports the miss after the quick re-checks (the roll call can complete on the other overlays' words) and keeps re-checking once a second for as long as the server says the request is pending, claiming the moment the element mounts; the server drops the stale report on an eligible claim and ends the watch by answering pending:false once the request resolved or timed out. The overlay tracks its participation per target: a replayed target is ignored only while this page is acting on it, and is otherwise handled again, so a busy or unresolvable page re-declines (idempotent) and an idle page claims. Unit, protocol, and contract cases updated; the decline answers now say whether the request is still pending. AI-assisted: implemented and tested with Claude Code under maintainer direction. Co-Authored-By: Claude Fable 5 --- crates/cli/tests/agent_target.rs | 12 +++---- crates/live/src/server_state.rs | 7 +++- docs/CLI-CONTRACT.md | 2 +- skill/scripts/live-browser.js | 53 ++++++++++++++++++++++++------ tests/live-agent-target.test.mjs | 8 ++--- tests/live-browser-source.test.mjs | 22 +++++++++---- 6 files changed, 76 insertions(+), 28 deletions(-) diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs index 0f054b95c..5c94dbf00 100644 --- a/crates/cli/tests/agent_target.rs +++ b/crates/cli/tests/agent_target.rs @@ -274,8 +274,8 @@ fn agent_target_roll_call_answers_busy_once_every_overlay_declined() { let held = s.hold(serde_json::json!({})); let pushed = a.next(|m| m["type"] == "agent_target"); let target_id = pushed["targetId"].as_str().unwrap().to_string(); - assert_eq!(s.claim(&target_id, "tab-a", false), serde_json::json!({ "ok": true, "granted": false })); - assert_eq!(s.claim(&target_id, "tab-b", false), serde_json::json!({ "ok": true, "granted": false })); + assert_eq!(s.claim(&target_id, "tab-a", false), serde_json::json!({ "ok": true, "granted": false, "pending": true }), "the first decline leaves the request pending"); + assert_eq!(s.claim(&target_id, "tab-b", false), serde_json::json!({ "ok": true, "granted": false, "pending": false }), "the last decline completes the roll call"); let (_, verdict) = held.join().unwrap(); assert_eq!(verdict["error"], serde_json::json!("busy")); assert_eq!(verdict["state"], serde_json::json!("CYCLING")); @@ -370,7 +370,7 @@ fn agent_target_roll_call_counts_overlays_not_connections() { let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string(); // One overlay behind two connections reports busy once: that completes // the roll call instead of waiting on a "second" report until timeout. - assert_eq!(s.claim(&target_id, "tab-a", false), serde_json::json!({ "ok": true, "granted": false })); + assert_eq!(s.claim(&target_id, "tab-a", false), serde_json::json!({ "ok": true, "granted": false, "pending": false }), "one overlay behind two connections completes the roll call alone"); let (_, verdict) = held.join().unwrap(); assert_eq!(verdict["error"], serde_json::json!("busy")); assert!(started.elapsed() < Duration::from_millis(350), "the busy verdict did not wait for the timeout"); @@ -390,8 +390,8 @@ fn agent_target_answers_the_resolution_verdict_when_no_page_can_serve() { // Both idle pages lack the element: each declines with its resolution // verdict instead of claiming. let decline = |cid: &str, raw: u64| serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": cid, "eligible": false, "state": "IDLE", "reason": "no_match", "result": { "ok": false, "error": "no_match", "selector": "h1", "matchCount": 0, "rawMatchCount": raw } }); - assert_eq!(post_json(s.port, "/agent-target-claim", decline("tab-a", 0)).1, serde_json::json!({ "ok": true, "granted": false })); - assert_eq!(post_json(s.port, "/agent-target-claim", decline("tab-b", 2)).1, serde_json::json!({ "ok": true, "granted": false })); + assert_eq!(post_json(s.port, "/agent-target-claim", decline("tab-a", 0)).1, serde_json::json!({ "ok": true, "granted": false, "pending": true })); + assert_eq!(post_json(s.port, "/agent-target-claim", decline("tab-b", 2)).1, serde_json::json!({ "ok": true, "granted": false, "pending": false }), "the last decline completes the roll call"); let (_, verdict) = held.join().unwrap(); assert_eq!(verdict["error"], serde_json::json!("no_match"), "{verdict}"); assert_eq!(verdict["ok"], serde_json::json!(false)); @@ -412,7 +412,7 @@ fn agent_target_prefers_busy_over_no_match_across_pages() { // The page that has the element is mid-session; the other page lacks it. // The agent should retry later, so busy outranks no_match. post_json(s.port, "/agent-target-claim", serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-b", "eligible": false, "state": "IDLE", "reason": "no_match", "result": { "ok": false, "error": "no_match", "matchCount": 0, "rawMatchCount": 0 } })); - assert_eq!(s.claim(&target_id, "tab-a", false), serde_json::json!({ "ok": true, "granted": false })); + assert_eq!(s.claim(&target_id, "tab-a", false), serde_json::json!({ "ok": true, "granted": false, "pending": false })); let (_, verdict) = held.join().unwrap(); assert_eq!(verdict["error"], serde_json::json!("busy"), "{verdict}"); assert_eq!(verdict["reason"], serde_json::json!("session_active")); diff --git a/crates/live/src/server_state.rs b/crates/live/src/server_state.rs index 1631f7519..0a1c4a8e0 100644 --- a/crates/live/src/server_state.rs +++ b/crates/live/src/server_state.rs @@ -894,7 +894,12 @@ impl ServerState { pending.claimed_until = 0; } self.maybe_complete_agent_target_roll_call(target_id); - return json!({ "ok": true, "granted": false }); + // `pending` tells a declining overlay whether to keep watching + // for a change of its word (an element that mounts late, a + // session that ends); false once the roll call or a result + // resolved the request. + let still_pending = self.pending_agent_targets.iter().any(|(k, _)| k == target_id); + return json!({ "ok": true, "granted": false, "pending": still_pending }); } // An eligible claim is the client's latest word: drop any earlier // busy report, so a busy verdict only ever counts tabs still busy. diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index e902334bd..c8a5ee0d2 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1481,7 +1481,7 @@ Binds `127.0.0.1:PORT`. CORS: if request has `Origin` and (origin is loopback ht | `POST /manual-edit` | | 410 `{"error":"/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits."}` | | `POST /agent-target` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | Agent-initiated targeting (the `generate` command). Validation (400 `{"error":}`, messages verbatim): `agent_target: selector is required`, `agent_target: selector too long` (>1000 chars), `agent_target: invalid action (valid: )`, `agent_target: count must be 1-8`, `agent_target: text must be a string of at most 500 chars`, `agent_target: index must be a positive integer (1-based)`, `agent_target: prompt must be a string of at most 2000 chars`, `agent_target: dryRun must be a boolean`. No SSE client → 200 `{ok:false, error:'no_browser_connected'}`. Otherwise mint an 8-hex `targetId`, broadcast `agent_target` (see 6.2), and **hold the response** until `/agent-target-result` resolves it, every connected overlay has declined (busy roll call, see `/agent-target-claim`), or `IMPECCABLE_AGENT_TARGET_TIMEOUT_MS` (default 15000) elapses: busy verdict `{ok:false, error:'busy', state, reason}` from the first report when any report exists, else `{ok:false, error:'browser_timeout', timeoutMs}`. The held reply is 200 `{targetId, ...result}`; shutdown resolves every held request with `{ok:false, error:'server_stopping'}`. | | `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). | -| `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason, result?}` under `clientId` (replacing an earlier report; `result` is the overlay's resolution verdict when `reason` is `no_match`, i.e. its page cannot resolve the selector), release the lease if this client holds it, answer `{ok:true, granted:false}`, then complete the roll call when no owner holds the lease and reports ≥ connected overlays. Verdict precedence: a report whose `reason` is not `no_match` (a tab that could serve later) → `{ok:false, error:'busy', state, reason}`; otherwise the first report's `result` (e.g. `no_match` with `rawMatchCount`, `invalid_selector`); the timeout uses the same precedence when any report exists. `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | +| `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason, result?}` under `clientId` (replacing an earlier report; `result` is the overlay's resolution verdict when `reason` is `no_match`, i.e. its page cannot resolve the selector), release the lease if this client holds it, answer `{ok:true, granted:false, pending}` (`pending` false once the request resolved, so a declining overlay knows whether to keep watching for a change of its word), then complete the roll call when no owner holds the lease and reports ≥ connected overlays. Verdict precedence: a report whose `reason` is not `no_match` (a tab that could serve later) → `{ok:false, error:'busy', state, reason}`; otherwise the first report's `result` (e.g. `no_match` with `rawMatchCount`, `invalid_selector`); the timeout uses the same precedence when any report exists. `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | | anything else | | 404 `Not found` | Pending-event summary in `/status.pendingEvents[]`: `{id, type, leased:boolean, leaseUntil:number|null}` plus for `manual_edit_apply`: `pageUrl, chunk, repair, evidencePath, agentAction, manualApplySummary:{pageUrl, chunk, entryCount, opCount, files[]}`. diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index a12c7ec64..76cf0ce1e 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -7236,6 +7236,7 @@ function declineAgentTargetBusy(msg, busy) { busyDeclinedTargets.set(msg.targetId, msg); + noteAgentTarget(msg.targetId, 'declined'); claimAgentTarget(msg.targetId, { eligible: false, state, reason: busy }); } @@ -7265,7 +7266,8 @@ if (busy) { declineAgentTargetBusy(msg, busy); return; } if (declineAgentTargetUnresolvable(msg)) return; claimAgentTarget(msg.targetId, { eligible: true }).then((claim) => { - if (claim.granted) { actOnAgentTarget(msg); return; } + if (claim.granted) { noteAgentTarget(msg.targetId, 'acting'); actOnAgentTarget(msg); return; } + noteAgentTarget(msg.targetId, 'denied'); if (!claim.pending) return; setTimeout(() => claimAndActOnAgentTarget(msg), AGENT_TARGET_RESCUE_RETRY_MS); }); @@ -7279,11 +7281,18 @@ } } - // Targets this page already answered (claimed, declined, or acted on). - // The server replays pending targets to every connection that opens, and - // an EventSource reconnect opens one for a page that already heard the - // target, so a replay must not start a second claim or a second Go. - const agentTargetsSeen = []; + // This page's participation in each target it heard: 'acting' once a + // claim was granted (so a replay never starts a second Go), else the word + // it last gave. The server replays pending targets to every connection + // that opens. After a reconnect that overlapped the old connection the + // server still holds this page's word; after one that did not, it dropped + // the word on the close, so a replayed target is handled again: a busy or + // unresolvable page re-declines (idempotent), an idle page claims. + const agentTargetsSeen = new Map(); + function noteAgentTarget(targetId, status) { + agentTargetsSeen.set(targetId, status); + if (agentTargetsSeen.size > 100) agentTargetsSeen.delete(agentTargetsSeen.keys().next().value); + } // Only a page that can resolve the target claims it. A tab whose page // lacks the element declines with its resolution verdict instead, so a @@ -7298,6 +7307,13 @@ // moment the element mounts, and only the last miss is reported. The // server's timeout still bounds the whole exchange. const AGENT_TARGET_RESOLVE_RETRY_MS = [300, 700, 1500]; + // After the quick re-checks the page reports the miss (so the roll call + // can complete on the other overlays' words) and keeps re-checking at + // this cadence for as long as the server says the request is pending, + // claiming the moment the element mounts; the server drops the stale + // report on an eligible claim and ends the watch by answering + // pending:false once the request resolved or timed out. + const AGENT_TARGET_RESOLVE_WATCH_MS = 1000; function declineAgentTargetUnresolvable(msg) { const probe = resolveAgentTargetElement(msg); @@ -7308,7 +7324,11 @@ function retryAgentTargetResolution(msg, attempt, lastError) { if (attempt >= AGENT_TARGET_RESOLVE_RETRY_MS.length) { - claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: lastError }); + noteAgentTarget(msg.targetId, 'declined'); + claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: lastError }).then((answer) => { + if (!answer.pending) return; + setTimeout(() => watchAgentTargetResolution(msg, lastError), AGENT_TARGET_RESOLVE_WATCH_MS); + }); return; } setTimeout(() => { @@ -7321,11 +7341,24 @@ }, AGENT_TARGET_RESOLVE_RETRY_MS[attempt]); } + function watchAgentTargetResolution(msg, lastError) { + if (agentTargetOverlayGone() || agentTargetsSeen.get(msg.targetId) === 'acting') return; + const busy = agentTargetBusyReason(); + if (busy) { declineAgentTargetBusy(msg, busy); return; } + const probe = resolveAgentTargetElement(msg); + if (!probe.error) { claimAndActOnAgentTarget(msg); return; } + // Still unresolvable: re-decline (idempotent) and let the answer say + // whether to keep watching. + claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: probe.error || lastError }).then((answer) => { + if (!answer.pending) return; + setTimeout(() => watchAgentTargetResolution(msg, lastError), AGENT_TARGET_RESOLVE_WATCH_MS); + }); + } + function handleAgentTarget(msg) { if (!msg || typeof msg.targetId !== 'string') return; - if (agentTargetsSeen.includes(msg.targetId)) return; - agentTargetsSeen.push(msg.targetId); - if (agentTargetsSeen.length > 100) agentTargetsSeen.shift(); + if (agentTargetsSeen.get(msg.targetId) === 'acting') return; + noteAgentTarget(msg.targetId, 'heard'); const busy = agentTargetBusyReason(); if (busy) { // Roll call: a busy tab reports itself and never acts. The server diff --git a/tests/live-agent-target.test.mjs b/tests/live-agent-target.test.mjs index c19e2da6f..85cc18d8a 100644 --- a/tests/live-agent-target.test.mjs +++ b/tests/live-agent-target.test.mjs @@ -358,11 +358,11 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA token: server.token, selector: 'h1', action: 'bolder', count: 3, }); const pushed = await tabA.next((m) => m.type === 'agent_target'); - for (const clientId of ['tab-a', 'tab-b']) { + for (const [clientId, pending] of [['tab-a', true], ['tab-b', false]]) { const report = await (await postJson(server, '/agent-target-claim', { token: server.token, targetId: pushed.targetId, clientId, eligible: false, state: 'CYCLING', reason: 'session_active', })).json(); - assert.deepEqual(report, { ok: true, granted: false }); + assert.deepEqual(report, { ok: true, granted: false, pending }, 'a decline says whether the request is still pending'); } const verdict = await (await held).json(); assert.equal(verdict.error, 'busy'); @@ -654,12 +654,12 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA token: server.token, selector: 'h1', action: 'bolder', count: 3, }); const pushed = await tabA.next((m) => m.type === 'agent_target'); - for (const [clientId, raw] of [['tab-a', 0], ['tab-b', 3]]) { + for (const [clientId, raw, pending] of [['tab-a', 0, true], ['tab-b', 3, false]]) { const report = await (await postJson(server, '/agent-target-claim', { token: server.token, targetId: pushed.targetId, clientId, eligible: false, state: 'IDLE', reason: 'no_match', result: { ok: false, error: 'no_match', selector: 'h1', matchCount: 0, rawMatchCount: raw }, })).json(); - assert.deepEqual(report, { ok: true, granted: false }); + assert.deepEqual(report, { ok: true, granted: false, pending }); } const verdict = await (await held).json(); assert.equal(verdict.error, 'no_match'); diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index 9585e006c..8a058f837 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -848,8 +848,13 @@ describe('live-browser source contracts', () => { ); assert.match( SOURCE, - /function handleAgentTarget\(msg\) \{[\s\S]{0,120}?if \(agentTargetsSeen\.includes\(msg\.targetId\)\) return;/, - 'a replayed target this page already handled must not start a second claim or Go', + /function handleAgentTarget\(msg\) \{[\s\S]{0,120}?if \(agentTargetsSeen\.get\(msg\.targetId\) === 'acting'\) return;/, + 'a replayed target this page is acting on must not start a second claim or Go; any other replay is handled again', + ); + assert.match( + SOURCE, + /if \(claim\.granted\) \{ noteAgentTarget\(msg\.targetId, 'acting'\); actOnAgentTarget\(msg\); return; \}/, + 'a granted claim marks the target as acting before Go', ); // A page that cannot resolve the target never claims it: a first-wins // claim would otherwise let the wrong page answer no_match for a target @@ -871,8 +876,13 @@ describe('live-browser source contracts', () => { ); assert.match( SOURCE, - /function retryAgentTargetResolution\(msg, attempt, lastError\) \{[\s\S]{0,200}?reason: 'no_match', result: lastError[\s\S]{0,600}?if \(!probe\.error\) \{ claimAndActOnAgentTarget\(msg\); return; \}/, - 'the page claims the moment the element mounts, and reports only the last miss', + /function retryAgentTargetResolution\(msg, attempt, lastError\) \{[\s\S]{0,300}?reason: 'no_match', result: lastError[\s\S]{0,120}?if \(!answer\.pending\) return;[\s\S]{0,120}?watchAgentTargetResolution\(msg, lastError\)/, + 'after the quick re-checks the page reports the miss and keeps watching while the server says the request is pending', + ); + assert.match( + SOURCE, + /function watchAgentTargetResolution\(msg, lastError\) \{[\s\S]{0,400}?if \(!probe\.error\) \{ claimAndActOnAgentTarget\(msg\); return; \}[\s\S]{0,500}?if \(!answer\.pending\) return;/, + 'a late mount turns into a claim, and the server ends the watch', ); // The per-origin session cache must not let a tab on another page of // the app resume this page's session (it would sit in GENERATING for a @@ -890,8 +900,8 @@ describe('live-browser source contracts', () => { ); assert.equal( (SOURCE.match(/claimAndActOnAgentTarget\(msg\)/g) || []).length, - 5, - 'the first claim, the busy-to-idle re-claim, and the resolution re-check must share the rescue path (definition, three call sites, the retry)', + 6, + 'the first claim, the busy-to-idle re-claim, the resolution re-check, and the resolution watch must share the rescue path (definition, four call sites, the retry)', ); }); From 79a27051a2607197101c60f05d1afdabd6006085 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Wed, 9 Sep 2026 21:26:21 +0500 Subject: [PATCH 11/42] Address review: hold an all-no_match roll call open for a resolution grace When the unresolvable page's decline was the last word, the roll call completed on it, the answer said pending:false, and the page's watcher never started, so an element that mounted a moment later was still answered no_match. A page's no_match is a provisional word: the server now keeps an all-no_match roll call open for IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS (default 3000) after the first such report, re-judging when the grace lapses, so a page that keeps watching can still claim (the stale report is dropped on its eligible claim); a busy report still answers at once. The overlay reports a miss immediately and re-checks every half second for as long as the answer says pending. A genuine no_match now takes about the grace instead of tens of milliseconds, inside the server's hold. Rust integration case for the late mount claiming within the grace, the protocol case, and the contract assertions updated; the contract documents the grace and its env override. AI-assisted: implemented and tested with Claude Code under maintainer direction. Co-Authored-By: Claude Fable 5 --- crates/cli/tests/agent_target.rs | 28 +++++++++++++- crates/live/src/server_state.rs | 59 ++++++++++++++++++++++++++++-- docs/CLI-CONTRACT.md | 2 +- skill/scripts/live-browser.js | 50 +++++++++---------------- tests/live-agent-target.test.mjs | 40 ++++++++++++++++++-- tests/live-browser-source.test.mjs | 16 ++++---- 6 files changed, 145 insertions(+), 50 deletions(-) diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs index 5c94dbf00..1263202ef 100644 --- a/crates/cli/tests/agent_target.rs +++ b/crates/cli/tests/agent_target.rs @@ -139,6 +139,7 @@ impl Server { // override exists exactly for this. The lease shrinks with it. .env("IMPECCABLE_AGENT_TARGET_TIMEOUT_MS", "400") .env("IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS", "250") + .env("IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS", "150") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() @@ -391,12 +392,15 @@ fn agent_target_answers_the_resolution_verdict_when_no_page_can_serve() { // verdict instead of claiming. let decline = |cid: &str, raw: u64| serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": cid, "eligible": false, "state": "IDLE", "reason": "no_match", "result": { "ok": false, "error": "no_match", "selector": "h1", "matchCount": 0, "rawMatchCount": raw } }); assert_eq!(post_json(s.port, "/agent-target-claim", decline("tab-a", 0)).1, serde_json::json!({ "ok": true, "granted": false, "pending": true })); - assert_eq!(post_json(s.port, "/agent-target-claim", decline("tab-b", 2)).1, serde_json::json!({ "ok": true, "granted": false, "pending": false }), "the last decline completes the roll call"); + // Every page said no_match: the roll call stays open for the resolution + // grace (150ms here), so the last decline is still answered pending. + assert_eq!(post_json(s.port, "/agent-target-claim", decline("tab-b", 2)).1, serde_json::json!({ "ok": true, "granted": false, "pending": true }), "an all-no_match roll call stays open for the grace"); let (_, verdict) = held.join().unwrap(); assert_eq!(verdict["error"], serde_json::json!("no_match"), "{verdict}"); assert_eq!(verdict["ok"], serde_json::json!(false)); assert_eq!(verdict["targetId"], serde_json::json!(target_id)); - assert!(started.elapsed() < Duration::from_millis(350), "answered by the roll call, not the timeout"); + let elapsed = started.elapsed(); + assert!(elapsed >= Duration::from_millis(140) && elapsed < Duration::from_millis(380), "answered when the grace lapsed, not before and not by the timeout: {elapsed:?}"); let _ = &mut b; } @@ -418,3 +422,23 @@ fn agent_target_prefers_busy_over_no_match_across_pages() { assert_eq!(verdict["reason"], serde_json::json!("session_active")); let _ = &mut b; } + +#[test] +fn agent_target_lets_a_late_mount_claim_within_the_resolution_grace() { + let s = Server::start("late-mount"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + a.next(|m| m["type"] == "connected"); + let held = s.hold(serde_json::json!({})); + let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string(); + // The only page cannot resolve the target yet: its decline leaves the + // request pending for the grace instead of answering no_match. + let decline = serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": "tab-a", "eligible": false, "state": "IDLE", "reason": "no_match", "result": { "ok": false, "error": "no_match", "matchCount": 0, "rawMatchCount": 0 } }); + assert_eq!(post_json(s.port, "/agent-target-claim", decline).1, serde_json::json!({ "ok": true, "granted": false, "pending": true })); + std::thread::sleep(Duration::from_millis(60)); + // The element mounted: the same page claims and serves. + assert_eq!(s.claim(&target_id, "tab-a", true), serde_json::json!({ "ok": true, "granted": true, "pending": true })); + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccdd" })); + let (_, verdict) = held.join().unwrap(); + assert_eq!(verdict["ok"], serde_json::json!(true), "{verdict}"); + assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd")); +} diff --git a/crates/live/src/server_state.rs b/crates/live/src/server_state.rs index 0a1c4a8e0..b3798561c 100644 --- a/crates/live/src/server_state.rs +++ b/crates/live/src/server_state.rs @@ -65,6 +65,9 @@ pub struct AgentTargetPending { pub claimed_until: i64, pub reports: Vec, pub timer_gen: u64, + /// While every report says `no_match`, the roll call stays open until + /// this instant: a page whose element mounts late can still claim. + pub resolve_grace_until: Option, } /// One pre-apply file snapshot entry (`{ exists, content }`). @@ -736,6 +739,17 @@ impl ServerState { .unwrap_or(3_000) } + /// A page's `no_match` is a provisional word: an element can mount after + /// the page first looked (a route still rendering, an HMR swap). When + /// every connected overlay says `no_match`, the roll call stays open for + /// this long after the first such report, so a page that keeps watching + /// can still claim; a busy report answers at once regardless. + pub fn agent_target_resolve_grace_ms(&self) -> i64 { + env_positive_ms(&self.env, "IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS") + .map(|v| v as i64) + .unwrap_or(3_000) + } + /// Hold a new agent target: mint its id, broadcast the push, arm the /// timeout. Returns the id and the receiver the route blocks on. pub fn register_agent_target(&mut self, mut payload: Map) -> (String, Receiver) { @@ -763,6 +777,7 @@ impl ServerState { claimed_until: 0, reports: Vec::new(), timer_gen, + resolve_grace_until: None, }, )); self.broadcast(&payload); @@ -810,22 +825,56 @@ impl ServerState { /// whenever a report lands and whenever an overlay leaves. pub fn maybe_complete_agent_target_roll_call(&mut self, target_id: &str) { let connected = self.connected_overlay_count(); + let now = now_i64(); let verdict = self .pending_agent_targets .iter() .find(|(k, _)| k == target_id) .and_then(|(_, p)| { if p.owner.is_some() || p.reports.is_empty() || p.reports.len() < connected { - None - } else { - Some(agent_target_verdict_from_reports(p)) + return None; } + let all_no_match = p.reports.iter().all(|r| r.reason.as_str() == Some("no_match")); + if all_no_match && p.resolve_grace_until.map(|until| now < until).unwrap_or(false) { + // Every page says no_match, but one may still be + // watching a late mount: the grace timer re-runs this + // check when it lapses. + return None; + } + Some(agent_target_verdict_from_reports(p)) }); if let Some(verdict) = verdict { self.resolve_agent_target(target_id, verdict); } } + /// Arm the resolution grace on the first `no_match` report: the roll + /// call is re-judged when it lapses (the lapse alone never resolves; the + /// check re-reads the reports, so a claim or a busy word in between + /// takes precedence). + fn arm_agent_target_resolve_grace(&mut self, target_id: &str) { + let grace_ms = self.agent_target_resolve_grace_ms(); + let Some((_, pending)) = self + .pending_agent_targets + .iter_mut() + .find(|(k, _)| k == target_id) + else { + return; + }; + if pending.resolve_grace_until.is_some() { + return; + } + pending.resolve_grace_until = Some(now_i64() + grace_ms); + let weak = self.self_ref.clone(); + let id = target_id.to_string(); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(grace_ms.max(0) as u64 + 5)); + if let Some(shared) = weak.upgrade() { + lock(&shared).maybe_complete_agent_target_roll_call(&id); + } + }); + } + /// A disconnected overlay's word no longer counts: drop its busy report, /// hand back a lease it held (a rescuer's next claim is granted at once /// instead of after the lease lapses), and re-judge each roll call @@ -879,6 +928,7 @@ impl ServerState { return json!({ "ok": true, "granted": false, "pending": false }); }; if !eligible { + let reason_is_no_match = reason.as_str() == Some("no_match"); pending.reports.retain(|r| r.client_id != client_id); pending.reports.push(AgentTargetReport { client_id: client_id.to_string(), @@ -893,6 +943,9 @@ impl ServerState { pending.owner = None; pending.claimed_until = 0; } + if reason_is_no_match { + self.arm_agent_target_resolve_grace(target_id); + } self.maybe_complete_agent_target_roll_call(target_id); // `pending` tells a declining overlay whether to keep watching // for a change of its word (an element that mounts late, a diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index c8a5ee0d2..ab305dff7 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1481,7 +1481,7 @@ Binds `127.0.0.1:PORT`. CORS: if request has `Origin` and (origin is loopback ht | `POST /manual-edit` | | 410 `{"error":"/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits."}` | | `POST /agent-target` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | Agent-initiated targeting (the `generate` command). Validation (400 `{"error":}`, messages verbatim): `agent_target: selector is required`, `agent_target: selector too long` (>1000 chars), `agent_target: invalid action (valid: )`, `agent_target: count must be 1-8`, `agent_target: text must be a string of at most 500 chars`, `agent_target: index must be a positive integer (1-based)`, `agent_target: prompt must be a string of at most 2000 chars`, `agent_target: dryRun must be a boolean`. No SSE client → 200 `{ok:false, error:'no_browser_connected'}`. Otherwise mint an 8-hex `targetId`, broadcast `agent_target` (see 6.2), and **hold the response** until `/agent-target-result` resolves it, every connected overlay has declined (busy roll call, see `/agent-target-claim`), or `IMPECCABLE_AGENT_TARGET_TIMEOUT_MS` (default 15000) elapses: busy verdict `{ok:false, error:'busy', state, reason}` from the first report when any report exists, else `{ok:false, error:'browser_timeout', timeoutMs}`. The held reply is 200 `{targetId, ...result}`; shutdown resolves every held request with `{ok:false, error:'server_stopping'}`. | | `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). | -| `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason, result?}` under `clientId` (replacing an earlier report; `result` is the overlay's resolution verdict when `reason` is `no_match`, i.e. its page cannot resolve the selector), release the lease if this client holds it, answer `{ok:true, granted:false, pending}` (`pending` false once the request resolved, so a declining overlay knows whether to keep watching for a change of its word), then complete the roll call when no owner holds the lease and reports ≥ connected overlays. Verdict precedence: a report whose `reason` is not `no_match` (a tab that could serve later) → `{ok:false, error:'busy', state, reason}`; otherwise the first report's `result` (e.g. `no_match` with `rawMatchCount`, `invalid_selector`); the timeout uses the same precedence when any report exists. `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | +| `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason, result?}` under `clientId` (replacing an earlier report; `result` is the overlay's resolution verdict when `reason` is `no_match`, i.e. its page cannot resolve the selector), release the lease if this client holds it, answer `{ok:true, granted:false, pending}` (`pending` false once the request resolved, so a declining overlay knows whether to keep watching for a change of its word), then complete the roll call when no owner holds the lease and reports ≥ connected overlays. Verdict precedence: a report whose `reason` is not `no_match` (a tab that could serve later) → `{ok:false, error:'busy', state, reason}` at once; when every report is `no_match` the roll call stays open for `IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS` (default 3000) after the first such report (a page whose element mounts late keeps re-checking while its decline answers `pending:true`, and an eligible claim drops its stale report), then answers the first report's `result` (e.g. `no_match` with `rawMatchCount`, `invalid_selector`); the timeout uses the same precedence when any report exists. `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | | anything else | | 404 `Not found` | Pending-event summary in `/status.pendingEvents[]`: `{id, type, leased:boolean, leaseUntil:number|null}` plus for `manual_edit_apply`: `pageUrl, chunk, repair, evidencePath, agentAction, manualApplySummary:{pageUrl, chunk, entryCount, opCount, files[]}`. diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 76cf0ce1e..8b59c454d 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -7306,39 +7306,28 @@ // it is re-checked a few times over about two seconds, claiming the // moment the element mounts, and only the last miss is reported. The // server's timeout still bounds the whole exchange. - const AGENT_TARGET_RESOLVE_RETRY_MS = [300, 700, 1500]; - // After the quick re-checks the page reports the miss (so the roll call - // can complete on the other overlays' words) and keeps re-checking at - // this cadence for as long as the server says the request is pending, - // claiming the moment the element mounts; the server drops the stale - // report on an eligible claim and ends the watch by answering - // pending:false once the request resolved or timed out. - const AGENT_TARGET_RESOLVE_WATCH_MS = 1000; + // The page reports the miss at once (so the other overlays' words can + // complete the roll call) and keeps re-checking at this cadence for as + // long as the server says the request is pending: the server holds an + // all-no_match roll call open for a short grace precisely so a late mount + // can still be claimed, drops the stale report on an eligible claim, and + // ends the watch by answering pending:false once the request resolved or + // timed out. + const AGENT_TARGET_RESOLVE_WATCH_MS = 500; function declineAgentTargetUnresolvable(msg) { const probe = resolveAgentTargetElement(msg); if (!probe.error) return false; - retryAgentTargetResolution(msg, 0, probe.error); + reportAgentTargetUnresolvable(msg, probe.error); return true; } - function retryAgentTargetResolution(msg, attempt, lastError) { - if (attempt >= AGENT_TARGET_RESOLVE_RETRY_MS.length) { - noteAgentTarget(msg.targetId, 'declined'); - claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: lastError }).then((answer) => { - if (!answer.pending) return; - setTimeout(() => watchAgentTargetResolution(msg, lastError), AGENT_TARGET_RESOLVE_WATCH_MS); - }); - return; - } - setTimeout(() => { - if (agentTargetOverlayGone()) return; - const busy = agentTargetBusyReason(); - if (busy) { declineAgentTargetBusy(msg, busy); return; } - const probe = resolveAgentTargetElement(msg); - if (!probe.error) { claimAndActOnAgentTarget(msg); return; } - retryAgentTargetResolution(msg, attempt + 1, probe.error); - }, AGENT_TARGET_RESOLVE_RETRY_MS[attempt]); + function reportAgentTargetUnresolvable(msg, error) { + noteAgentTarget(msg.targetId, 'declined'); + claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: error }).then((answer) => { + if (!answer.pending) return; + setTimeout(() => watchAgentTargetResolution(msg, error), AGENT_TARGET_RESOLVE_WATCH_MS); + }); } function watchAgentTargetResolution(msg, lastError) { @@ -7347,12 +7336,9 @@ if (busy) { declineAgentTargetBusy(msg, busy); return; } const probe = resolveAgentTargetElement(msg); if (!probe.error) { claimAndActOnAgentTarget(msg); return; } - // Still unresolvable: re-decline (idempotent) and let the answer say - // whether to keep watching. - claimAgentTarget(msg.targetId, { eligible: false, state, reason: 'no_match', result: probe.error || lastError }).then((answer) => { - if (!answer.pending) return; - setTimeout(() => watchAgentTargetResolution(msg, lastError), AGENT_TARGET_RESOLVE_WATCH_MS); - }); + // Still unresolvable: re-report (idempotent); the answer says whether + // the server is still holding the request open. + reportAgentTargetUnresolvable(msg, probe.error || lastError); } function handleAgentTarget(msg) { diff --git a/tests/live-agent-target.test.mjs b/tests/live-agent-target.test.mjs index 85cc18d8a..d1f63391d 100644 --- a/tests/live-agent-target.test.mjs +++ b/tests/live-agent-target.test.mjs @@ -158,7 +158,7 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA // exists exactly for this. server = await startServer(8497, { cwd: tmp, - env: { IMPECCABLE_AGENT_TARGET_TIMEOUT_MS: '400' }, + env: { IMPECCABLE_AGENT_TARGET_TIMEOUT_MS: '400', IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS: '150' }, }); }); @@ -654,24 +654,56 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA token: server.token, selector: 'h1', action: 'bolder', count: 3, }); const pushed = await tabA.next((m) => m.type === 'agent_target'); - for (const [clientId, raw, pending] of [['tab-a', 0, true], ['tab-b', 3, false]]) { + for (const [clientId, raw] of [['tab-a', 0], ['tab-b', 3]]) { const report = await (await postJson(server, '/agent-target-claim', { token: server.token, targetId: pushed.targetId, clientId, eligible: false, state: 'IDLE', reason: 'no_match', result: { ok: false, error: 'no_match', selector: 'h1', matchCount: 0, rawMatchCount: raw }, })).json(); - assert.deepEqual(report, { ok: true, granted: false, pending }); + // Every page said no_match: the roll call stays open for the + // resolution grace (150ms here), so both declines are answered pending. + assert.deepEqual(report, { ok: true, granted: false, pending: true }); } const verdict = await (await held).json(); assert.equal(verdict.error, 'no_match'); assert.equal(verdict.ok, false); assert.equal(verdict.targetId, pushed.targetId); - assert.ok(Date.now() - startedAt < 350, 'answered by the roll call, not the timeout'); + const elapsed = Date.now() - startedAt; + assert.ok(elapsed >= 140 && elapsed < 380, `answered when the grace lapsed, not before and not by the timeout (${elapsed}ms)`); } finally { tabA.close(); tabB.close(); } }); + it('lets a page that declined as unresolvable claim once its element mounts, within the grace', async () => { + const tab = await openSseClient(server, { clientId: 'tab-a' }); + try { + await tab.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tab.next((m) => m.type === 'agent_target'); + const declined = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: false, state: 'IDLE', reason: 'no_match', + result: { ok: false, error: 'no_match', matchCount: 0, rawMatchCount: 0 }, + })).json(); + assert.deepEqual(declined, { ok: true, granted: false, pending: true }, 'the only page declining leaves the request pending for the grace'); + await new Promise((r) => setTimeout(r, 60)); + const claim = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, + })).json(); + assert.deepEqual(claim, { ok: true, granted: true, pending: true }, 'the late mount is served'); + await postJson(server, '/agent-target-result', { + token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + }); + const verdict = await (await held).json(); + assert.equal(verdict.ok, true); + assert.equal(verdict.sessionId, 'aabbccdd'); + } finally { + tab.close(); + } + }); + it('prefers busy over no_match, so the agent retries when the right page is mid-session', async () => { const tabA = await openSseClient(server, { clientId: 'tab-a' }); const tabB = await openSseClient(server, { clientId: 'tab-b' }); diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index 8a058f837..be1d4f89a 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -871,18 +871,18 @@ describe('live-browser source contracts', () => { ); assert.match( SOURCE, - /function declineAgentTargetUnresolvable\(msg\) \{[\s\S]{0,200}?resolveAgentTargetElement\(msg\)[\s\S]{0,120}?retryAgentTargetResolution\(msg, 0, probe\.error\)/, - 'a failed resolution is re-checked before it becomes this page\'s word', + /function declineAgentTargetUnresolvable\(msg\) \{[\s\S]{0,200}?resolveAgentTargetElement\(msg\)[\s\S]{0,120}?reportAgentTargetUnresolvable\(msg, probe\.error\)/, + 'a failed resolution is reported at once so the roll call can proceed on the other overlays\' words', ); assert.match( SOURCE, - /function retryAgentTargetResolution\(msg, attempt, lastError\) \{[\s\S]{0,300}?reason: 'no_match', result: lastError[\s\S]{0,120}?if \(!answer\.pending\) return;[\s\S]{0,120}?watchAgentTargetResolution\(msg, lastError\)/, - 'after the quick re-checks the page reports the miss and keeps watching while the server says the request is pending', + /function reportAgentTargetUnresolvable\(msg, error\) \{[\s\S]{0,300}?reason: 'no_match', result: error[\s\S]{0,120}?if \(!answer\.pending\) return;[\s\S]{0,120}?watchAgentTargetResolution\(msg, error\)/, + 'the page reports the miss and keeps watching while the server says the request is pending', ); assert.match( SOURCE, - /function watchAgentTargetResolution\(msg, lastError\) \{[\s\S]{0,400}?if \(!probe\.error\) \{ claimAndActOnAgentTarget\(msg\); return; \}[\s\S]{0,500}?if \(!answer\.pending\) return;/, - 'a late mount turns into a claim, and the server ends the watch', + /function watchAgentTargetResolution\(msg, lastError\) \{[\s\S]{0,400}?if \(!probe\.error\) \{ claimAndActOnAgentTarget\(msg\); return; \}[\s\S]{0,300}?reportAgentTargetUnresolvable\(msg, probe\.error \|\| lastError\)/, + 'a late mount turns into a claim; otherwise the page re-reports and the server ends the watch', ); // The per-origin session cache must not let a tab on another page of // the app resume this page's session (it would sit in GENERATING for a @@ -900,8 +900,8 @@ describe('live-browser source contracts', () => { ); assert.equal( (SOURCE.match(/claimAndActOnAgentTarget\(msg\)/g) || []).length, - 6, - 'the first claim, the busy-to-idle re-claim, the resolution re-check, and the resolution watch must share the rescue path (definition, four call sites, the retry)', + 5, + 'the first claim, the busy-to-idle re-claim, and the resolution watch must share the rescue path (definition, three call sites, the retry)', ); }); From a3bb21cbde17821cd65a346647c56e025d7dc6b9 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Thu, 10 Sep 2026 00:17:37 +0500 Subject: [PATCH 12/42] Address review: one Go per tab, declines for a granted miss, and grace per overlay Four review threads on the agent-target protocol and the hook stand-down. Overlay: a tab acting on one target is busy for every other target (`agent_target_in_flight`), so two held generate requests can never both be claimed by one tab and the second Go can never overwrite the session the first one minted. Every exit from actOnAgentTarget ends the acting state, and teardown clears the target ledger, so a Go that never happened does not refuse the next connection's targets. A miss after a granted claim now declines (handing the lease back so another page or a remount can serve) instead of posting a result that ended the request for every tab. Hook: the live-preview marker probe runs before the per-session edit cap, so a file already past the cap stands down for a variants wrap instead of emitting the suppression notice. Server: each overlay's first no_match word extends the resolution grace by the full window (its watch re-reports do not), so an overlay that reports after another page's grace lapsed still gets its late-mount watch instead of completing the roll call with a no_match verdict. Tests: a Rust and a Node protocol case for the late overlay's grace, a hook case for the cap-then-wrap order, and contract pins for the busy check, the decline on a granted miss, and the teardown clear. Written with AI assistance (Claude). Co-Authored-By: Claude Fable 5 --- crates/cli/tests/agent_target.rs | 26 +++++++++++++++++++++ crates/hook/src/hook.rs | 14 ++++++++++++ crates/hook/tests/hook_tests.rs | 32 ++++++++++++++++++++++++++ crates/live/src/live_generate.rs | 30 ++++++++++++++++++++++++- crates/live/src/server_state.rs | 23 +++++++++++++------ docs/CLI-CONTRACT.md | 2 +- skill/scripts/live-browser.js | 36 ++++++++++++++++++++++-------- tests/live-agent-target.test.mjs | 35 +++++++++++++++++++++++++++++ tests/live-browser-source.test.mjs | 14 ++++++++++++ 9 files changed, 194 insertions(+), 18 deletions(-) diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs index 1263202ef..1d4a670b4 100644 --- a/crates/cli/tests/agent_target.rs +++ b/crates/cli/tests/agent_target.rs @@ -442,3 +442,29 @@ fn agent_target_lets_a_late_mount_claim_within_the_resolution_grace() { assert_eq!(verdict["ok"], serde_json::json!(true), "{verdict}"); assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd")); } + +#[test] +fn agent_target_late_overlay_first_no_match_extends_the_grace() { + let s = Server::start("late-grace"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + let mut b = Overlay::connect(s.port, &s.token, "tab-b"); + a.next(|m| m["type"] == "connected"); + b.next(|m| m["type"] == "connected"); + let held = s.hold(serde_json::json!({})); + let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string(); + let decline = |cid: &str| serde_json::json!({ "token": s.token, "targetId": target_id, "clientId": cid, "eligible": false, "state": "IDLE", "reason": "no_match", "result": { "ok": false, "error": "no_match", "matchCount": 0, "rawMatchCount": 0 } }); + assert_eq!(post_json(s.port, "/agent-target-claim", decline("tab-a")).1["pending"], serde_json::json!(true)); + // Tab A's grace (150ms) lapses before tab B says its first word. + std::thread::sleep(Duration::from_millis(200)); + let reported_at = Instant::now(); + let answer = post_json(s.port, "/agent-target-claim", decline("tab-b")).1; + assert_eq!(answer["pending"], serde_json::json!(true), "a late overlay's first no_match word extends the grace: {answer}"); + // Tab B's watcher finds the element within its grace and claims. + std::thread::sleep(Duration::from_millis(60)); + assert_eq!(s.claim(&target_id, "tab-b", true)["granted"], serde_json::json!(true)); + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "aabbccdd" })); + let (_, verdict) = held.join().unwrap(); + assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd"), "{verdict}"); + assert!(reported_at.elapsed() < Duration::from_millis(400)); + let _ = &mut b; +} diff --git a/crates/hook/src/hook.rs b/crates/hook/src/hook.rs index da0e3d655..0f962c40f 100644 --- a/crates/hook/src/hook.rs +++ b/crates/hook/src/hook.rs @@ -240,6 +240,20 @@ pub fn run_hook(rt: &Runtime, stdin: &str) -> RunResult { } } + // A live variant session owns a file carrying preview markers: stand + // down before the per-session edit cap can turn the variants wrap + // into a suppression notice. + if primary_files.contains(file_path) { + if let Ok(bytes) = std::fs::read(file_path) { + if crate::hook_lib::has_live_preview_markers(&String::from_utf8_lossy(&bytes)) { + if live_preview_edit.is_none() { + live_preview_edit = Some(file_path.clone()); + } + last_skip = "live-preview"; + continue; + } + } + } let use_html_engine = match configured { Some(c) => c.engine == "html", None => ext == ".html" || ext == ".htm", diff --git a/crates/hook/tests/hook_tests.rs b/crates/hook/tests/hook_tests.rs index 1a26c681b..614efe546 100644 --- a/crates/hook/tests/hook_tests.rs +++ b/crates/hook/tests/hook_tests.rs @@ -2282,3 +2282,35 @@ fn run_hook_stands_down_for_the_whole_edit_when_the_primary_carries_live_markers let audited = audit_str(&skipped.audit, "file").unwrap_or("").replace('\\', "/"); assert!(audited.ends_with("src/App.jsx"), "the audit names the edited file, not the companion: {audited}"); } + +#[test] +fn run_hook_stands_down_before_the_edit_cap_can_suppress_a_live_file() { + // A file edited past the per-session cap would be skipped as + // "suppressed" (with the notice) before its content is read. A live + // wrap on such a file must stand down instead, every time. + let t = Tmp::new(); + let cwd = t.path(); + std::fs::create_dir_all(t.0.join(".impeccable")).unwrap(); + let r = rt(&cwd); + // Seven plain edits cross the cap: the 7th carries the notice. + let css = t.write("src/b.css", GRADIENT_CSS); + let mut outputs = Vec::new(); + for _ in 0..7 { + outputs.push(hook::run_hook(&r, &edit_event(&cwd, &css, "cap"))); + } + assert_eq!(outputs[6].audit["suppressed"], json!(true)); + assert!(outputs[6].stdout.contains("Suppressing further design hints")); + // Now a live session carbonizes into that same file: stand down, never + // suppress. + t.write( + "src/b.css", + &format!("/* impeccable-carbonize-start ab12cd34 */\n{GRADIENT_CSS}/* impeccable-carbonize-end ab12cd34 */\n"), + ); + for i in 0..3 { + let out = hook::run_hook(&r, &edit_event(&cwd, &css, "cap")); + assert_eq!(out.stdout, "", "edit {i}: nothing emitted"); + assert_eq!(out.audit["skipped"], json!("live-preview"), "edit {i}"); + assert!(out.audit.get("suppressed").is_none(), "edit {i}: {:?}", out.audit); + assert!(out.audit.get("editCount").is_none(), "edit {i}: the cap is not bumped for a live wrap"); + } +} diff --git a/crates/live/src/live_generate.rs b/crates/live/src/live_generate.rs index ac0be1330..5869d2d8c 100644 --- a/crates/live/src/live_generate.rs +++ b/crates/live/src/live_generate.rs @@ -137,7 +137,13 @@ fn instructions_for(result: &Map, self_cmd: &str) -> Option format!("The selector matched {} elements. Either target their common container instead, or disambiguate with --text \"\" or --index <1-based position>. The candidates are listed in this output.", n("matchCount")), "index_out_of_range" => format!("--index is out of range: only {} match(es). Use an index from 1 to {}.", n("matchCount"), n("matchCount")), - "busy" => format!("A live session is already mid-flight (browser state {}). Let the user finish or discard it in the browser, or handle the pending event in your poll loop, then rerun.", s("state")), + "busy" => { + if s("reason") == "agent_target_in_flight" { + "That tab is already acting on another generate request. Handle that request's pending event in your poll loop, or wait for its session to end, then rerun.".to_string() + } else { + format!("A live session is already mid-flight (browser state {}). Let the user finish or discard it in the browser, or handle the pending event in your poll loop, then rerun.", s("state")) + } + } "go_failed" => format!("The overlay could not start generation from the picked state (browser state {}). Reload the app page and rerun this command.", s("state")), "server_stopping" => format!("The live helper server is shutting down. Re-run the live boot ({} live), reopen the page, then rerun this command.", self_cmd), _ => return None, @@ -346,3 +352,25 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { 1 } } + +#[cfg(test)] +mod tests { + use super::*; + + fn busy(reason: &str) -> Map { + let mut m = Map::new(); + m.insert("ok".into(), json!(false)); + m.insert("error".into(), json!("busy")); + m.insert("state".into(), json!("CONFIGURING")); + m.insert("reason".into(), json!(reason)); + m + } + + #[test] + fn busy_instructions_tell_the_agent_whose_session_is_in_the_way() { + let own = instructions_for(&busy("agent_target_in_flight"), "impeccable").unwrap(); + assert!(own.contains("already acting on another generate request"), "{own}"); + let user = instructions_for(&busy("session_active"), "impeccable").unwrap(); + assert!(user.contains("browser state CONFIGURING"), "{user}"); + } +} diff --git a/crates/live/src/server_state.rs b/crates/live/src/server_state.rs index b3798561c..d484447f5 100644 --- a/crates/live/src/server_state.rs +++ b/crates/live/src/server_state.rs @@ -848,10 +848,12 @@ impl ServerState { } } - /// Arm the resolution grace on the first `no_match` report: the roll - /// call is re-judged when it lapses (the lapse alone never resolves; the - /// check re-reads the reports, so a claim or a busy word in between - /// takes precedence). + /// Each overlay's first `no_match` word extends the resolution grace by + /// the full window, so a page that reports after another page's grace + /// lapsed still gets its watch; the roll call is re-judged when the + /// latest grace lapses (the lapse alone never resolves; the check + /// re-reads the reports, so a claim or a busy word in between takes + /// precedence). The target's timeout bounds the sum. fn arm_agent_target_resolve_grace(&mut self, target_id: &str) { let grace_ms = self.agent_target_resolve_grace_ms(); let Some((_, pending)) = self @@ -861,10 +863,11 @@ impl ServerState { else { return; }; - if pending.resolve_grace_until.is_some() { + let until = now_i64() + grace_ms; + if pending.resolve_grace_until.map(|u| u >= until).unwrap_or(false) { return; } - pending.resolve_grace_until = Some(now_i64() + grace_ms); + pending.resolve_grace_until = Some(until); let weak = self.self_ref.clone(); let id = target_id.to_string(); std::thread::spawn(move || { @@ -929,6 +932,12 @@ impl ServerState { }; if !eligible { let reason_is_no_match = reason.as_str() == Some("no_match"); + // Only an overlay's first no_match word extends the grace: its + // re-reports while watching must not keep the roll call open. + let first_no_match_from_client = !pending + .reports + .iter() + .any(|r| r.client_id == client_id && r.reason.as_str() == Some("no_match")); pending.reports.retain(|r| r.client_id != client_id); pending.reports.push(AgentTargetReport { client_id: client_id.to_string(), @@ -943,7 +952,7 @@ impl ServerState { pending.owner = None; pending.claimed_until = 0; } - if reason_is_no_match { + if reason_is_no_match && first_no_match_from_client { self.arm_agent_target_resolve_grace(target_id); } self.maybe_complete_agent_target_roll_call(target_id); diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index ab305dff7..e3b6b2b67 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1481,7 +1481,7 @@ Binds `127.0.0.1:PORT`. CORS: if request has `Origin` and (origin is loopback ht | `POST /manual-edit` | | 410 `{"error":"/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits."}` | | `POST /agent-target` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | Agent-initiated targeting (the `generate` command). Validation (400 `{"error":}`, messages verbatim): `agent_target: selector is required`, `agent_target: selector too long` (>1000 chars), `agent_target: invalid action (valid: )`, `agent_target: count must be 1-8`, `agent_target: text must be a string of at most 500 chars`, `agent_target: index must be a positive integer (1-based)`, `agent_target: prompt must be a string of at most 2000 chars`, `agent_target: dryRun must be a boolean`. No SSE client → 200 `{ok:false, error:'no_browser_connected'}`. Otherwise mint an 8-hex `targetId`, broadcast `agent_target` (see 6.2), and **hold the response** until `/agent-target-result` resolves it, every connected overlay has declined (busy roll call, see `/agent-target-claim`), or `IMPECCABLE_AGENT_TARGET_TIMEOUT_MS` (default 15000) elapses: busy verdict `{ok:false, error:'busy', state, reason}` from the first report when any report exists, else `{ok:false, error:'browser_timeout', timeoutMs}`. The held reply is 200 `{targetId, ...result}`; shutdown resolves every held request with `{ok:false, error:'server_stopping'}`. | | `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). | -| `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason, result?}` under `clientId` (replacing an earlier report; `result` is the overlay's resolution verdict when `reason` is `no_match`, i.e. its page cannot resolve the selector), release the lease if this client holds it, answer `{ok:true, granted:false, pending}` (`pending` false once the request resolved, so a declining overlay knows whether to keep watching for a change of its word), then complete the roll call when no owner holds the lease and reports ≥ connected overlays. Verdict precedence: a report whose `reason` is not `no_match` (a tab that could serve later) → `{ok:false, error:'busy', state, reason}` at once; when every report is `no_match` the roll call stays open for `IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS` (default 3000) after the first such report (a page whose element mounts late keeps re-checking while its decline answers `pending:true`, and an eligible claim drops its stale report), then answers the first report's `result` (e.g. `no_match` with `rawMatchCount`, `invalid_selector`); the timeout uses the same precedence when any report exists. `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | +| `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason, result?}` under `clientId` (replacing an earlier report; `result` is the overlay's resolution verdict when `reason` is `no_match`, i.e. its page cannot resolve the selector), release the lease if this client holds it, answer `{ok:true, granted:false, pending}` (`pending` false once the request resolved, so a declining overlay knows whether to keep watching for a change of its word), then complete the roll call when no owner holds the lease and reports ≥ connected overlays. Verdict precedence: a report whose `reason` is not `no_match` (a tab that could serve later) → `{ok:false, error:'busy', state, reason}` at once; when every report is `no_match` the roll call stays open for `IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS` (default 3000) after each overlay's first such report (a late reporter extends the grace by the full window; a page whose element mounts late keeps re-checking while its decline answers `pending:true`, an eligible claim drops its stale report, and the overlay declines rather than posting a result when the element is gone after its claim), then answers the first report's `result` (e.g. `no_match` with `rawMatchCount`, `invalid_selector`); the timeout uses the same precedence when any report exists. `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | | anything else | | 404 `Not found` | Pending-event summary in `/status.pendingEvents[]`: `{id, type, leased:boolean, leaseUntil:number|null}` plus for `manual_edit_apply`: `pageUrl, chunk, repair, evidencePath, agentAction, manualApplySummary:{pageUrl, chunk, entryCount, opCount, files[]}`. diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 8b59c454d..54c992095 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -7221,9 +7221,16 @@ .catch(() => ({ granted: false, pending: false })); } - function agentTargetBusyReason() { + // `exceptTargetId` is the target this call is about: a tab acting on it + // is not busy for itself, but it is busy for every other target, or two + // held requests could both be claimed here and the second Go would + // overwrite the session the first one minted. + function agentTargetBusyReason(exceptTargetId) { if (pendingApplyInFlight) return 'manual_apply_in_flight'; if (state !== 'IDLE' && state !== 'PICKING' && state !== 'CONFIGURING') return 'session_active'; + for (const [targetId, status] of agentTargetsSeen) { + if (status === 'acting' && targetId !== exceptTargetId) return 'agent_target_in_flight'; + } return null; } @@ -7262,7 +7269,7 @@ // and the busy-to-idle re-claim share this. function claimAndActOnAgentTarget(msg) { if (agentTargetOverlayGone()) return; - const busy = agentTargetBusyReason(); + const busy = agentTargetBusyReason(msg.targetId); if (busy) { declineAgentTargetBusy(msg, busy); return; } if (declineAgentTargetUnresolvable(msg)) return; claimAgentTarget(msg.targetId, { eligible: true }).then((claim) => { @@ -7332,7 +7339,7 @@ function watchAgentTargetResolution(msg, lastError) { if (agentTargetOverlayGone() || agentTargetsSeen.get(msg.targetId) === 'acting') return; - const busy = agentTargetBusyReason(); + const busy = agentTargetBusyReason(msg.targetId); if (busy) { declineAgentTargetBusy(msg, busy); return; } const probe = resolveAgentTargetElement(msg); if (!probe.error) { claimAndActOnAgentTarget(msg); return; } @@ -7345,7 +7352,7 @@ if (!msg || typeof msg.targetId !== 'string') return; if (agentTargetsSeen.get(msg.targetId) === 'acting') return; noteAgentTarget(msg.targetId, 'heard'); - const busy = agentTargetBusyReason(); + const busy = agentTargetBusyReason(msg.targetId); if (busy) { // Roll call: a busy tab reports itself and never acts. The server // answers `busy` the moment every connected overlay has reported, so @@ -7363,8 +7370,10 @@ function actOnAgentTarget(msg) { if (agentTargetOverlayGone()) return; - const reply = (result) => postAgentTargetResult(msg.targetId, result); - const busy = agentTargetBusyReason(); + // Every exit ends this tab's acting state, so a later target is not + // refused for a Go that already happened or never will. + const reply = (result) => { noteAgentTarget(msg.targetId, 'done'); postAgentTargetResult(msg.targetId, result); }; + const busy = agentTargetBusyReason(msg.targetId); if (busy) { // Turned busy between claim and act: report it, which also hands the // lease back so the roll call can complete or a rescuer can claim. @@ -7372,7 +7381,13 @@ return; } const resolved = resolveAgentTargetElement(msg); - if (resolved.error) { reply(resolved.error); return; } + if (resolved.error) { + // The element went away between claim and act. A result would end the + // request for every tab; a decline hands the lease back so another + // page or a remount can still serve it. + reportAgentTargetUnresolvable(msg, resolved.error); + return; + } const el = resolved.el; if (msg.dryRun) { reply({ @@ -7392,7 +7407,7 @@ // lease lapsed while it scrolled (a rescuer took over) stops here, so // one request never gets two Go presses. claimAgentTarget(msg.targetId, { eligible: true }).then((renewal) => { - if (!renewal.granted) return; + if (!renewal.granted) { noteAgentTarget(msg.targetId, 'done'); return; } // An insert placement left mid-configure gives way, exactly as a // click outside it does in handleClick. if (state === 'CONFIGURING' && configureKind === 'insert') cancelInsertConfigure(); @@ -12049,8 +12064,11 @@ void main() { /** Full teardown: remove all UI, disconnect SSE, clean up. */ function teardown() { // Declined targets die with the overlay: the IDLE transition below must - // not re-claim a lease this page can no longer act on. + // not re-claim a lease this page can no longer act on. So does the + // target ledger: an 'acting' entry from a Go that never happened must + // not refuse every target the next connection hears. busyDeclinedTargets.clear(); + agentTargetsSeen.clear(); stopAgentStatusPoll(); hideAgentPollTooltip(); if (agentPollTooltipEl) { diff --git a/tests/live-agent-target.test.mjs b/tests/live-agent-target.test.mjs index d1f63391d..11f4369f1 100644 --- a/tests/live-agent-target.test.mjs +++ b/tests/live-agent-target.test.mjs @@ -704,6 +704,41 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA } }); + it('extends the grace on a late overlay\'s first no_match word, so it still gets its watch', async () => { + const tabA = await openSseClient(server, { clientId: 'tab-a' }); + const tabB = await openSseClient(server, { clientId: 'tab-b' }); + try { + await tabA.next((m) => m.type === 'connected'); + await tabB.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tabA.next((m) => m.type === 'agent_target'); + const decline = (clientId) => postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId, eligible: false, state: 'IDLE', reason: 'no_match', + result: { ok: false, error: 'no_match', matchCount: 0, rawMatchCount: 0 }, + }); + assert.equal((await (await decline('tab-a')).json()).pending, true); + // Tab A's grace (150ms) lapses before tab B says its first word. + await new Promise((r) => setTimeout(r, 200)); + const late = await (await decline('tab-b')).json(); + assert.equal(late.pending, true, 'a late overlay\'s first no_match word extends the grace'); + await new Promise((r) => setTimeout(r, 60)); + const claim = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true, + })).json(); + assert.equal(claim.granted, true, 'the late overlay\'s watcher claims within its grace'); + await postJson(server, '/agent-target-result', { + token: server.token, targetId: pushed.targetId, ok: true, matchCount: 1, sessionId: 'aabbccdd', + }); + const verdict = await (await held).json(); + assert.equal(verdict.sessionId, 'aabbccdd'); + } finally { + tabA.close(); + tabB.close(); + } + }); + it('prefers busy over no_match, so the agent retries when the right page is mid-session', async () => { const tabA = await openSseClient(server, { clientId: 'tab-a' }); const tabB = await openSseClient(server, { clientId: 'tab-b' }); diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index be1d4f89a..b3f8a3fd2 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -826,6 +826,10 @@ describe('live-browser source contracts', () => { it('re-claims busy-declined agent targets only while the overlay can still serve them', () => { const teardownSource = SOURCE.match(/function teardown\(\) \{[\s\S]*?\n \}/)?.[0] || ''; const clearAt = teardownSource.indexOf('busyDeclinedTargets.clear();'); + assert.ok( + teardownSource.includes('agentTargetsSeen.clear();'), + 'teardown clears the target ledger, so a stale acting entry never refuses the next connection\'s targets', + ); const idleAt = teardownSource.indexOf("setLiveState('IDLE')"); assert.ok(clearAt >= 0 && idleAt > clearAt, 'teardown must drop declined targets before its IDLE transition, or a dead overlay re-claims a lease'); assert.match( @@ -856,6 +860,16 @@ describe('live-browser source contracts', () => { /if \(claim\.granted\) \{ noteAgentTarget\(msg\.targetId, 'acting'\); actOnAgentTarget\(msg\); return; \}/, 'a granted claim marks the target as acting before Go', ); + assert.match( + SOURCE, + /function agentTargetBusyReason\(exceptTargetId\) \{[\s\S]{0,500}?status === 'acting' && targetId !== exceptTargetId\) return 'agent_target_in_flight';/, + 'a tab acting on one target is busy for every other target, so two held requests can never both mint a session here', + ); + assert.match( + SOURCE, + /function actOnAgentTarget\(msg\) \{[\s\S]{0,900}?if \(resolved\.error\) \{[\s\S]{0,400}?reportAgentTargetUnresolvable\(msg, resolved\.error\);/, + 'a miss after a granted claim declines (handing the lease back) instead of ending the request for every tab', + ); // A page that cannot resolve the target never claims it: a first-wins // claim would otherwise let the wrong page answer no_match for a target // another page has. From 335945525de2198f82212ce9ee26033c92049ca2 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Thu, 10 Sep 2026 00:31:19 +0500 Subject: [PATCH 13/42] Address review: a done target stays off-limits for a replay Marking a target done on reply opened a window: an EventSource reconnect replays the still-pending target while the result is on the wire, the tab is GENERATING by then, so it declined busy, the server handed the lease back mid-resolution, and another tab could claim and fire a second Go. `agentTargetTaken` now covers both acting and done, so a replay of a target this page took a lease on is ignored, and the late-mount watch stops on either. Contract pins for the guard and the watch. Written with AI assistance (Claude). Co-Authored-By: Claude Fable 5 --- skill/scripts/live-browser.js | 27 +++++++++++++++++++-------- tests/live-browser-source.test.mjs | 14 ++++++++++++-- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 54c992095..276eb8b95 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -7289,18 +7289,29 @@ } // This page's participation in each target it heard: 'acting' once a - // claim was granted (so a replay never starts a second Go), else the word - // it last gave. The server replays pending targets to every connection - // that opens. After a reconnect that overlapped the old connection the - // server still holds this page's word; after one that did not, it dropped - // the word on the close, so a replayed target is handled again: a busy or - // unresolvable page re-declines (idempotent), an idle page claims. + // claim was granted, 'done' once it replied (or stood down from a lapsed + // lease), else the word it last gave. The server replays pending targets + // to every connection that opens. After a reconnect that overlapped the + // old connection the server still holds this page's word; after one that + // did not, it dropped the word on the close, so a replayed target is + // handled again: a busy or unresolvable page re-declines (idempotent), an + // idle page claims. const agentTargetsSeen = new Map(); function noteAgentTarget(targetId, status) { agentTargetsSeen.set(targetId, status); if (agentTargetsSeen.size > 100) agentTargetsSeen.delete(agentTargetsSeen.keys().next().value); } + // A target this page took a lease on is off-limits for a replay: while + // acting (a second claim or Go), and once done, because its result may + // still be on the wire and this tab is GENERATING by then, so handling + // the replay would decline busy, hand the lease back mid-resolution, and + // let another tab fire a second Go. + function agentTargetTaken(targetId) { + const status = agentTargetsSeen.get(targetId); + return status === 'acting' || status === 'done'; + } + // Only a page that can resolve the target claims it. A tab whose page // lacks the element declines with its resolution verdict instead, so a // first-wins claim never lets the wrong page answer for a target that @@ -7338,7 +7349,7 @@ } function watchAgentTargetResolution(msg, lastError) { - if (agentTargetOverlayGone() || agentTargetsSeen.get(msg.targetId) === 'acting') return; + if (agentTargetOverlayGone() || agentTargetTaken(msg.targetId)) return; const busy = agentTargetBusyReason(msg.targetId); if (busy) { declineAgentTargetBusy(msg, busy); return; } const probe = resolveAgentTargetElement(msg); @@ -7350,7 +7361,7 @@ function handleAgentTarget(msg) { if (!msg || typeof msg.targetId !== 'string') return; - if (agentTargetsSeen.get(msg.targetId) === 'acting') return; + if (agentTargetTaken(msg.targetId)) return; noteAgentTarget(msg.targetId, 'heard'); const busy = agentTargetBusyReason(msg.targetId); if (busy) { diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index b3f8a3fd2..a306c3b9b 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -852,8 +852,18 @@ describe('live-browser source contracts', () => { ); assert.match( SOURCE, - /function handleAgentTarget\(msg\) \{[\s\S]{0,120}?if \(agentTargetsSeen\.get\(msg\.targetId\) === 'acting'\) return;/, - 'a replayed target this page is acting on must not start a second claim or Go; any other replay is handled again', + /function handleAgentTarget\(msg\) \{[\s\S]{0,120}?if \(agentTargetTaken\(msg\.targetId\)\) return;/, + 'a replayed target this page took a lease on must not start a second claim, Go, or decline; any other replay is handled again', + ); + assert.match( + SOURCE, + /function agentTargetTaken\(targetId\) \{[\s\S]{0,200}?status === 'acting' \|\| status === 'done';/, + 'a done target is still taken: a replay while its result is on the wire must not decline busy and hand the lease to a second Go', + ); + assert.match( + SOURCE, + /function watchAgentTargetResolution\(msg, lastError\) \{\s*if \(agentTargetOverlayGone\(\) \|\| agentTargetTaken\(msg\.targetId\)\) return;/, + 'the late-mount watch stops once this page took the lease', ); assert.match( SOURCE, From b5210471fbadcd8cb43ad7af499b9ff587bf6740 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Thu, 10 Sep 2026 00:48:15 +0500 Subject: [PATCH 14/42] Address review: the generate event resolves the agent target it serves A winning overlay could reload after handleGo() minted a session but before its result post landed. The close released its lease, the server replayed the still-pending target, and another tab (or the reloaded page, once it abandoned the unknown session) could claim it and fire a second Go for a request that already had a session. The overlay now names the target on the generate event it fires for it (`agentTarget: {targetId, result}`, the same result it posts), and the helper resolves the pending request the moment that event is accepted, stripping the envelope before journaling. Whichever of the event and the result post lands first answers; a page that dies between Go and its result cannot leave the request pending, and a request whose event never reached the helper is served exactly once by the rescuer. Tests: a Rust integration case and a Node protocol case (claim, Go event without a result post, verdict carries the session, a late claim finds nothing pending, the journal carries no envelope), contract pins for the handoff, and the contract doc. Written with AI assistance (Claude). Co-Authored-By: Claude Fable 5 --- crates/cli/tests/agent_target.rs | 33 +++++++++++++++++++++++++++ crates/live/src/live_server.rs | 19 ++++++++++++++++ docs/CLI-CONTRACT.md | 2 +- skill/scripts/live-browser.js | 30 ++++++++++++++++++++++++- tests/live-agent-target.test.mjs | 36 ++++++++++++++++++++++++++++++ tests/live-browser-source.test.mjs | 10 +++++++++ 6 files changed, 128 insertions(+), 2 deletions(-) diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs index 1d4a670b4..5aac8037c 100644 --- a/crates/cli/tests/agent_target.rs +++ b/crates/cli/tests/agent_target.rs @@ -468,3 +468,36 @@ fn agent_target_late_overlay_first_no_match_extends_the_grace() { assert!(reported_at.elapsed() < Duration::from_millis(400)); let _ = &mut b; } + +#[test] +fn agent_target_resolves_from_the_generate_event_when_the_result_never_lands() { + let s = Server::start("event-backstop"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + let mut b = Overlay::connect(s.port, &s.token, "tab-b"); + a.next(|m| m["type"] == "connected"); + b.next(|m| m["type"] == "connected"); + let held = s.hold(serde_json::json!({})); + let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string(); + assert_eq!(s.claim(&target_id, "tab-a", true)["granted"], serde_json::json!(true)); + // Tab A fires Go: its generate event names the target it serves. Its + // own result post never lands (the page reloaded right after Go). + let result = serde_json::json!({ "ok": true, "matchCount": 1, "sessionId": "aabbccdd", "action": "bolder", "count": 3, "element": { "tag": "h1" } }); + let (status, ack) = post_json(s.port, "/events", serde_json::json!({ + "token": s.token, "type": "generate", "id": "aabbccdd", "action": "bolder", "count": 3, "pageUrl": "/", + "element": { "tagName": "h1", "outerHTML": "

Hero

" }, + "agentTarget": { "targetId": target_id, "result": result }, + })); + assert_eq!(status, 200, "{ack}"); + let (_, verdict) = held.join().unwrap(); + assert_eq!(verdict["sessionId"], serde_json::json!("aabbccdd"), "{verdict}"); + assert_eq!(verdict["targetId"], serde_json::json!(target_id)); + // Nothing is left pending for a rescuer to take over with a second Go. + let late = s.claim(&target_id, "tab-b", true); + assert_eq!(late["granted"], serde_json::json!(false), "{late}"); + assert_eq!(late["pending"], serde_json::json!(false), "{late}"); + // The journal carries the event without the envelope. + let journal = std::fs::read_to_string(s.dir.join(".impeccable/live/sessions/aabbccdd.jsonl")).unwrap(); + assert!(journal.contains("generate"), "{journal}"); + assert!(!journal.contains("agentTarget"), "{journal}"); + let _ = &mut b; +} diff --git a/crates/live/src/live_server.rs b/crates/live/src/live_server.rs index 2b4bd7fab..0b04aa8b3 100644 --- a/crates/live/src/live_server.rs +++ b/crates/live/src/live_server.rs @@ -1183,7 +1183,19 @@ fn handle_events_post( respond(stream, cors, json_res(400, json!({ "error": error }))); return; } + // A generate event may name the agent target it serves. The helper + // resolves that request from the event as well as from + // /agent-target-result, so a page that dies between Go and its result + // cannot leave the request pending for a second Go elsewhere. The + // envelope never reaches the journal or the poller. + let mut msg = msg; let mut msg_obj = msg_obj; + let agent_target = if ty == "generate" { + msg_obj.remove("agentTarget"); + msg.as_object_mut().and_then(|o| o.remove("agentTarget")) + } else { + None + }; crate::server_state::strip_poller_owned_event_fields(&mut msg_obj); let mut st = lock(shared); if ty == "agent_phase" { @@ -1260,6 +1272,13 @@ fn handle_events_post( if ty != "checkpoint" && ty != "variant_mounted" && !orphaned_discard { st.enqueue_event(msg_obj); } + if let Some(Value::Object(envelope)) = agent_target { + if let (Some(Value::String(target_id)), Some(result @ Value::Object(_))) = + (envelope.get("targetId"), envelope.get("result")) + { + st.resolve_agent_target(target_id, result.clone()); + } + } drop(st); respond(stream, cors, json_res(200, json!({ "ok": true }))); } diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index e3b6b2b67..e7457f95b 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1480,7 +1480,7 @@ Binds `127.0.0.1:PORT`. CORS: if request has `Origin` and (origin is loopback ht | `POST /manual-edit-discard?token=&pageUrl=` | 401 | see 10 | | `POST /manual-edit` | | 410 `{"error":"/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits."}` | | `POST /agent-target` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | Agent-initiated targeting (the `generate` command). Validation (400 `{"error":}`, messages verbatim): `agent_target: selector is required`, `agent_target: selector too long` (>1000 chars), `agent_target: invalid action (valid: )`, `agent_target: count must be 1-8`, `agent_target: text must be a string of at most 500 chars`, `agent_target: index must be a positive integer (1-based)`, `agent_target: prompt must be a string of at most 2000 chars`, `agent_target: dryRun must be a boolean`. No SSE client → 200 `{ok:false, error:'no_browser_connected'}`. Otherwise mint an 8-hex `targetId`, broadcast `agent_target` (see 6.2), and **hold the response** until `/agent-target-result` resolves it, every connected overlay has declined (busy roll call, see `/agent-target-claim`), or `IMPECCABLE_AGENT_TARGET_TIMEOUT_MS` (default 15000) elapses: busy verdict `{ok:false, error:'busy', state, reason}` from the first report when any report exists, else `{ok:false, error:'browser_timeout', timeoutMs}`. The held reply is 200 `{targetId, ...result}`; shutdown resolves every held request with `{ok:false, error:'server_stopping'}`. | -| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). | +| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). A `generate` event on `POST /events` may carry `agentTarget: {targetId, result}`: once the event is accepted, the server resolves that pending target with `result` (the envelope is stripped before journaling and never reaches the poller), so a page that dies between Go and its result cannot leave the request pending for a second Go elsewhere; whichever of the event and the result post lands first answers. | | `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason, result?}` under `clientId` (replacing an earlier report; `result` is the overlay's resolution verdict when `reason` is `no_match`, i.e. its page cannot resolve the selector), release the lease if this client holds it, answer `{ok:true, granted:false, pending}` (`pending` false once the request resolved, so a declining overlay knows whether to keep watching for a change of its word), then complete the roll call when no owner holds the lease and reports ≥ connected overlays. Verdict precedence: a report whose `reason` is not `no_match` (a tab that could serve later) → `{ok:false, error:'busy', state, reason}` at once; when every report is `no_match` the roll call stays open for `IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS` (default 3000) after each overlay's first such report (a late reporter extends the grace by the full window; a page whose element mounts late keeps re-checking while its decline answers `pending:true`, an eligible claim drops its stale report, and the overlay declines rather than posting a result when the element is gone after its claim), then answers the first report's `result` (e.g. `no_match` with `rawMatchCount`, `invalid_selector`); the timeout uses the same precedence when any report exists. `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | | anything else | | 404 `Not found` | diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 276eb8b95..80659f4b5 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -7211,6 +7211,10 @@ // it, and only the tab that holds the lease can renew it. const AGENT_TARGET_CLIENT_ID = id8(); + // The agent target an agent-initiated Go is serving: set by + // actOnAgentTarget around its handleGo call, read once by handleGo. + let agentTargetForGo = null; + function claimAgentTarget(targetId, report) { return fetch('http://localhost:' + PORT + '/agent-target-claim?token=' + TOKEN, { method: 'POST', @@ -7446,7 +7450,14 @@ updateBarContent('configure'); const input = uiGetById(PREFIX + '-input'); if (input) input.value = msg.prompt || ''; + // The target rides on the generate event too: the helper resolves + // the request from whichever lands first, so a page that dies + // between Go and its result cannot leave the request pending for a + // second Go elsewhere. + const candidate = describeAgentTargetCandidate(el); + agentTargetForGo = { targetId: msg.targetId, matchCount: resolved.matchCount, action: msg.action, count: msg.count, element: candidate }; handleGo(); + agentTargetForGo = null; if (state === 'GENERATING' && currentSessionId) { reply({ ok: true, @@ -7454,7 +7465,7 @@ sessionId: currentSessionId, action: msg.action, count: msg.count, - element: describeAgentTargetCandidate(el), + element: candidate, }); } else { reply({ ok: false, error: 'go_failed', state }); @@ -8175,6 +8186,23 @@ }; if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments; if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes; + if (agentTargetForGo) { + // An agent-initiated Go names the target it serves (see + // actOnAgentTarget): the helper resolves that request from this event + // as well as from the overlay's own result post. + basePayload.agentTarget = { + targetId: agentTargetForGo.targetId, + result: { + ok: true, + matchCount: agentTargetForGo.matchCount, + sessionId: currentSessionId, + action: agentTargetForGo.action, + count: agentTargetForGo.count, + element: agentTargetForGo.element, + }, + }; + agentTargetForGo = null; + } // Hide the interactive overlay so it doesn't linger during generation. hideAnnotOverlay(); diff --git a/tests/live-agent-target.test.mjs b/tests/live-agent-target.test.mjs index 11f4369f1..45eab63a8 100644 --- a/tests/live-agent-target.test.mjs +++ b/tests/live-agent-target.test.mjs @@ -739,6 +739,42 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA } }); + it('resolves the request from the generate event that serves it, so a page that dies before its result cannot leave it pending', async () => { + const tabA = await openSseClient(server, { clientId: 'tab-a' }); + const tabB = await openSseClient(server, { clientId: 'tab-b' }); + try { + await tabA.next((m) => m.type === 'connected'); + await tabB.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tabA.next((m) => m.type === 'agent_target'); + const claim = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, + })).json(); + assert.equal(claim.granted, true); + const result = { ok: true, matchCount: 1, sessionId: 'aabbccdd', action: 'bolder', count: 3, element: { tag: 'h1' } }; + const ack = await postJson(server, '/events', { + token: server.token, type: 'generate', id: 'aabbccdd', action: 'bolder', count: 3, pageUrl: '/', + element: { tagName: 'h1', outerHTML: '

Hero

' }, + agentTarget: { targetId: pushed.targetId, result }, + }); + assert.equal(ack.status, 200); + const verdict = await (await held).json(); + assert.equal(verdict.ok, true); + assert.equal(verdict.sessionId, 'aabbccdd'); + const late = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-b', eligible: true, + })).json(); + assert.deepEqual(late, { ok: true, granted: false, pending: false }, 'nothing is left for a rescuer to serve twice'); + const journal = readFileSync(join(tmp, '.impeccable/live/sessions/aabbccdd.jsonl'), 'utf-8'); + assert.ok(!journal.includes('agentTarget'), 'the envelope never reaches the journal'); + } finally { + tabA.close(); + tabB.close(); + } + }); + it('prefers busy over no_match, so the agent retries when the right page is mid-session', async () => { const tabA = await openSseClient(server, { clientId: 'tab-a' }); const tabB = await openSseClient(server, { clientId: 'tab-b' }); diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index a306c3b9b..b1b220010 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -865,6 +865,16 @@ describe('live-browser source contracts', () => { /function watchAgentTargetResolution\(msg, lastError\) \{\s*if \(agentTargetOverlayGone\(\) \|\| agentTargetTaken\(msg\.targetId\)\) return;/, 'the late-mount watch stops once this page took the lease', ); + assert.match( + SOURCE, + /agentTargetForGo = \{ targetId: msg\.targetId, matchCount: resolved\.matchCount, action: msg\.action, count: msg\.count, element: candidate \};\s*handleGo\(\);\s*agentTargetForGo = null;/, + 'the target rides on the Go event it serves, so the helper resolves it even if this page dies before its result lands', + ); + assert.match( + SOURCE, + /if \(agentTargetForGo\) \{[\s\S]{0,600}?basePayload\.agentTarget = \{[\s\S]{0,300}?sessionId: currentSessionId/, + 'handleGo attaches the agent target with the session it minted', + ); assert.match( SOURCE, /if \(claim\.granted\) \{ noteAgentTarget\(msg\.targetId, 'acting'\); actOnAgentTarget\(msg\); return; \}/, From 7adb81672d84160bc54e7414d2e5ed66bdcd82ee Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Thu, 10 Sep 2026 01:02:41 +0500 Subject: [PATCH 15/42] Address review: a superseded Go never opens a second session An overlay renews its lease right before Go, then captures and uploads the element before its generate event leaves. When that outlasts the lease and its result post is lost, a rescuer can claim the target and Go, and the helper accepted both generate events: two sessions for one request. The generate envelope now carries this page's clientId, and the helper refuses a generate event for a target that another page holds under a live lease, or that was already answered with a different session (`served_agent_targets`, recorded on every ok resolution): 409 `agent_target_already_served`, nothing journaled. The overlay treats that refusal like a foreign session and hands the surface back. The answering session's own event stays welcome, so the common path (result post first, then the event) is unchanged. Tests: two Rust integration cases (rival lease, answered elsewhere, welcome for the serving session) and a Node protocol case, contract pins for the envelope and the refusal handling, contract doc. Written with AI assistance (Claude). Co-Authored-By: Claude Fable 5 --- crates/cli/tests/agent_target.rs | 60 +++++++++++++++++++++++++++++- crates/live/src/live_server.rs | 18 +++++++++ crates/live/src/server_state.rs | 49 ++++++++++++++++++++++++ docs/CLI-CONTRACT.md | 2 +- skill/scripts/live-browser.js | 17 +++++++++ tests/live-agent-target.test.mjs | 37 +++++++++++++++++- tests/live-browser-source.test.mjs | 9 ++++- 7 files changed, 186 insertions(+), 6 deletions(-) diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs index 5aac8037c..43fe008c0 100644 --- a/crates/cli/tests/agent_target.rs +++ b/crates/cli/tests/agent_target.rs @@ -485,7 +485,7 @@ fn agent_target_resolves_from_the_generate_event_when_the_result_never_lands() { let (status, ack) = post_json(s.port, "/events", serde_json::json!({ "token": s.token, "type": "generate", "id": "aabbccdd", "action": "bolder", "count": 3, "pageUrl": "/", "element": { "tagName": "h1", "outerHTML": "

Hero

" }, - "agentTarget": { "targetId": target_id, "result": result }, + "agentTarget": { "targetId": target_id, "clientId": "tab-a", "result": result }, })); assert_eq!(status, 200, "{ack}"); let (_, verdict) = held.join().unwrap(); @@ -501,3 +501,61 @@ fn agent_target_resolves_from_the_generate_event_when_the_result_never_lands() { assert!(!journal.contains("agentTarget"), "{journal}"); let _ = &mut b; } + +fn generate_event_for(s: &Server, target_id: &str, id: &str, client: &str) -> serde_json::Value { + serde_json::json!({ + "token": s.token, "type": "generate", "id": id, "action": "bolder", "count": 3, "pageUrl": "/", + "element": { "tagName": "h1", "outerHTML": "

Hero

" }, + "agentTarget": { "targetId": target_id, "clientId": client, "result": { "ok": true, "matchCount": 1, "sessionId": id, "action": "bolder", "count": 3 } }, + }) +} + +#[test] +fn agent_target_refuses_a_generate_event_from_a_superseded_claimant() { + let s = Server::start("superseded"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + let mut b = Overlay::connect(s.port, &s.token, "tab-b"); + a.next(|m| m["type"] == "connected"); + b.next(|m| m["type"] == "connected"); + let held = s.hold(serde_json::json!({})); + let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string(); + assert_eq!(s.claim(&target_id, "tab-a", true)["granted"], serde_json::json!(true)); + // Tab A's lease (250ms) lapses while it is still capturing; tab B rescues. + std::thread::sleep(Duration::from_millis(300)); + assert_eq!(s.claim(&target_id, "tab-b", true)["granted"], serde_json::json!(true)); + // A's delayed event while B holds the lease: refused, nothing journaled. + let (status, body) = post_json(s.port, "/events", generate_event_for(&s, &target_id, "aaaaaaaa", "tab-a")); + assert_eq!(status, 409, "{body}"); + assert_eq!(body["error"], serde_json::json!("agent_target_already_served")); + assert!(body.get("sessionId").is_none(), "{body}"); + assert!(!s.dir.join(".impeccable/live/sessions/aaaaaaaa.jsonl").exists()); + // B's Go serves the request. + let (status, _) = post_json(s.port, "/events", generate_event_for(&s, &target_id, "bbbbbbbb", "tab-b")); + assert_eq!(status, 200); + let (_, verdict) = held.join().unwrap(); + assert_eq!(verdict["sessionId"], serde_json::json!("bbbbbbbb"), "{verdict}"); + // A's event once the request was answered elsewhere: refused, naming + // the session that serves it. + let (status, body) = post_json(s.port, "/events", generate_event_for(&s, &target_id, "aaaaaaa2", "tab-a")); + assert_eq!(status, 409, "{body}"); + assert_eq!(body["sessionId"], serde_json::json!("bbbbbbbb")); + assert!(!s.dir.join(".impeccable/live/sessions/aaaaaaa2.jsonl").exists()); + let _ = &mut b; +} + +#[test] +fn agent_target_welcomes_the_generate_event_of_the_session_that_answered() { + let s = Server::start("welcome"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + a.next(|m| m["type"] == "connected"); + let held = s.hold(serde_json::json!({})); + let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string(); + assert_eq!(s.claim(&target_id, "tab-a", true)["granted"], serde_json::json!(true)); + // The result post lands first (the common path), then the event. + post_json(s.port, "/agent-target-result", serde_json::json!({ "token": s.token, "targetId": target_id, "ok": true, "sessionId": "cccccccc" })); + let (_, verdict) = held.join().unwrap(); + assert_eq!(verdict["sessionId"], serde_json::json!("cccccccc"), "{verdict}"); + let (status, body) = post_json(s.port, "/events", generate_event_for(&s, &target_id, "cccccccc", "tab-a")); + assert_eq!(status, 200, "{body}"); + assert!(s.dir.join(".impeccable/live/sessions/cccccccc.jsonl").exists()); +} diff --git a/crates/live/src/live_server.rs b/crates/live/src/live_server.rs index 0b04aa8b3..bd5bf32ce 100644 --- a/crates/live/src/live_server.rs +++ b/crates/live/src/live_server.rs @@ -184,6 +184,7 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { next_apply_timer_gen: 0, pending_agent_targets: Vec::new(), next_agent_target_timer_gen: 0, + served_agent_targets: Vec::new(), shutting_down: false, cleaned_up: false, log_tx, @@ -1238,6 +1239,23 @@ fn handle_events_post( return; } } + if let Some(envelope) = agent_target.as_ref().and_then(Value::as_object) { + if let Some(served) = st.agent_target_served_elsewhere(envelope, id_str.as_deref()) { + // A superseded Go: this page's lease lapsed while it was still + // capturing and another page served the request. Journal + // nothing, so one request never gets two sessions. + drop(st); + let mut body = json!({ + "error": "agent_target_already_served", + "targetId": envelope.get("targetId").cloned().unwrap_or(Value::Null), + }); + if !served.is_empty() { + body["sessionId"] = Value::String(served); + } + respond(stream, cors, json_res(409, body)); + return; + } + } let missed = st.detect_missed_generation_completion(&msg_obj); if id_truthy { if let Err(e) = st.store.append_event(&msg) { diff --git a/crates/live/src/server_state.rs b/crates/live/src/server_state.rs index d484447f5..168c1d5a5 100644 --- a/crates/live/src/server_state.rs +++ b/crates/live/src/server_state.rs @@ -116,6 +116,10 @@ pub struct ServerState { /// Held-open agent targets keyed by targetId, in arrival order. pub pending_agent_targets: Vec<(String, AgentTargetPending)>, pub next_agent_target_timer_gen: u64, + /// Agent targets answered with a session, oldest first (bounded): a + /// generate event that names one of these under another session id is + /// a superseded Go and is refused. + pub served_agent_targets: Vec<(String, String)>, pub last_poll_at: i64, pub timed_out_apply_ids: Vec<(String, TimedOutApply)>, pub next_poll_id: u64, @@ -816,10 +820,55 @@ impl ServerState { return false; }; let (_, pending) = self.pending_agent_targets.remove(pos); + if result.get("ok") == Some(&Value::Bool(true)) { + if let Some(sid) = result.get("sessionId").and_then(Value::as_str) { + self.served_agent_targets + .push((target_id.to_string(), sid.to_string())); + if self.served_agent_targets.len() > 64 { + self.served_agent_targets.remove(0); + } + } + } let _ = pending.tx.send(result); true } + /// Whether a generate event naming `envelope.targetId`, sent by + /// `envelope.clientId` under `session_id`, is a superseded Go: the + /// target is still pending but another page holds a live lease on it + /// (this page's lease lapsed while it was capturing), or the request + /// was already answered with a different session. Returns the serving + /// session id, empty while the rival has not minted one yet. + pub fn agent_target_served_elsewhere( + &self, + envelope: &Map, + session_id: Option<&str>, + ) -> Option { + let target_id = envelope.get("targetId").and_then(Value::as_str)?; + let client_id = envelope + .get("clientId") + .and_then(Value::as_str) + .unwrap_or(""); + if let Some((_, pending)) = self + .pending_agent_targets + .iter() + .find(|(k, _)| k == target_id) + { + return match &pending.owner { + Some(owner) if owner != client_id && pending.claimed_until > now_i64() => { + Some(String::new()) + } + _ => None, + }; + } + self.served_agent_targets + .iter() + .rev() + .find(|(t, _)| t == target_id) + .filter(|(_, sid)| Some(sid.as_str()) != session_id) + .map(|(_, sid)| sid.clone()) + } + /// Every connected overlay has declined: answer busy now, not at the /// timeout. Judged against the connections of this moment, so it runs /// whenever a report lands and whenever an overlay leaves. diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index e7457f95b..edba94c28 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1480,7 +1480,7 @@ Binds `127.0.0.1:PORT`. CORS: if request has `Origin` and (origin is loopback ht | `POST /manual-edit-discard?token=&pageUrl=` | 401 | see 10 | | `POST /manual-edit` | | 410 `{"error":"/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits."}` | | `POST /agent-target` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | Agent-initiated targeting (the `generate` command). Validation (400 `{"error":}`, messages verbatim): `agent_target: selector is required`, `agent_target: selector too long` (>1000 chars), `agent_target: invalid action (valid: )`, `agent_target: count must be 1-8`, `agent_target: text must be a string of at most 500 chars`, `agent_target: index must be a positive integer (1-based)`, `agent_target: prompt must be a string of at most 2000 chars`, `agent_target: dryRun must be a boolean`. No SSE client → 200 `{ok:false, error:'no_browser_connected'}`. Otherwise mint an 8-hex `targetId`, broadcast `agent_target` (see 6.2), and **hold the response** until `/agent-target-result` resolves it, every connected overlay has declined (busy roll call, see `/agent-target-claim`), or `IMPECCABLE_AGENT_TARGET_TIMEOUT_MS` (default 15000) elapses: busy verdict `{ok:false, error:'busy', state, reason}` from the first report when any report exists, else `{ok:false, error:'browser_timeout', timeoutMs}`. The held reply is 200 `{targetId, ...result}`; shutdown resolves every held request with `{ok:false, error:'server_stopping'}`. | -| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). A `generate` event on `POST /events` may carry `agentTarget: {targetId, result}`: once the event is accepted, the server resolves that pending target with `result` (the envelope is stripped before journaling and never reaches the poller), so a page that dies between Go and its result cannot leave the request pending for a second Go elsewhere; whichever of the event and the result post lands first answers. | +| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). A `generate` event on `POST /events` may carry `agentTarget: {targetId, result}`: once the event is accepted, the server resolves that pending target with `result` (the envelope is stripped before journaling and never reaches the poller), so a page that dies between Go and its result cannot leave the request pending for a second Go elsewhere; whichever of the event and the result post lands first answers. The envelope also carries `clientId`: a generate event naming a target that another page now holds (a live lease, this page's having lapsed while it captured) or that was already answered with a different session is refused with 409 `{"error":"agent_target_already_served", targetId, sessionId?}` and journals nothing, and the overlay drops that local session. | | `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason, result?}` under `clientId` (replacing an earlier report; `result` is the overlay's resolution verdict when `reason` is `no_match`, i.e. its page cannot resolve the selector), release the lease if this client holds it, answer `{ok:true, granted:false, pending}` (`pending` false once the request resolved, so a declining overlay knows whether to keep watching for a change of its word), then complete the roll call when no owner holds the lease and reports ≥ connected overlays. Verdict precedence: a report whose `reason` is not `no_match` (a tab that could serve later) → `{ok:false, error:'busy', state, reason}` at once; when every report is `no_match` the roll call stays open for `IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS` (default 3000) after each overlay's first such report (a late reporter extends the grace by the full window; a page whose element mounts late keeps re-checking while its decline answers `pending:true`, an eligible claim drops its stale report, and the overlay declines rather than posting a result when the element is gone after its claim), then answers the first report's `result` (e.g. `no_match` with `rawMatchCount`, `invalid_selector`); the timeout uses the same precedence when any report exists. `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | | anything else | | 404 `Not found` | diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 80659f4b5..0ecfee894 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -7736,6 +7736,14 @@ }).then(async res => { if (res.ok) return res; const body = await res.json().catch(() => ({})); + // The helper refused to open a second session for an agent target + // another page already served (this page's lease lapsed while it was + // capturing): drop the local session and hand the surface back. + if (body.error === 'agent_target_already_served' && msg.type === 'generate' + && msg.id && msg.id === currentSessionId) { + abandonSupersededGo(msg.id); + return null; + } // The server refused to journal progress for a session it has never // seen: this browser is carrying state from another project or a // wiped store (two apps sharing a localhost port). Continuing to @@ -7757,6 +7765,14 @@ return sessionCreationGate.then(doSend); } + function abandonSupersededGo(sessionId) { + if (sessionId !== currentSessionId) return; + console.warn('[impeccable] Another page already served this agent target; clearing session ' + sessionId + '.'); + markSessionHandled(); + cleanup({ instantChrome: true }); + showToast('Another tab already served this request, so this session was cleared.', 6000); + } + let abandonedForeignSessionId = null; function abandonForeignSession(sessionId) { if (abandonedForeignSessionId === sessionId || sessionId !== currentSessionId) return; @@ -8192,6 +8208,7 @@ // as well as from the overlay's own result post. basePayload.agentTarget = { targetId: agentTargetForGo.targetId, + clientId: AGENT_TARGET_CLIENT_ID, result: { ok: true, matchCount: agentTargetForGo.matchCount, diff --git a/tests/live-agent-target.test.mjs b/tests/live-agent-target.test.mjs index 45eab63a8..b9e37a099 100644 --- a/tests/live-agent-target.test.mjs +++ b/tests/live-agent-target.test.mjs @@ -10,7 +10,7 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { execFile, execFileSync, spawn } from 'node:child_process'; @@ -757,7 +757,7 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA const ack = await postJson(server, '/events', { token: server.token, type: 'generate', id: 'aabbccdd', action: 'bolder', count: 3, pageUrl: '/', element: { tagName: 'h1', outerHTML: '

Hero

' }, - agentTarget: { targetId: pushed.targetId, result }, + agentTarget: { targetId: pushed.targetId, clientId: 'tab-a', result }, }); assert.equal(ack.status, 200); const verdict = await (await held).json(); @@ -775,6 +775,39 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA } }); + it('refuses a generate event for a target another session already answered, and welcomes that session\'s own', async () => { + const tabA = await openSseClient(server, { clientId: 'tab-a' }); + try { + await tabA.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tabA.next((m) => m.type === 'agent_target'); + const claim = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, + })).json(); + assert.equal(claim.granted, true); + await postJson(server, '/agent-target-result', { token: server.token, targetId: pushed.targetId, ok: true, sessionId: 'cccccccc' }); + assert.equal((await (await held).json()).sessionId, 'cccccccc'); + const event = (id, clientId) => postJson(server, '/events', { + token: server.token, type: 'generate', id, action: 'bolder', count: 3, pageUrl: '/', + element: { tagName: 'h1', outerHTML: '

Hero

' }, + agentTarget: { targetId: pushed.targetId, clientId, result: { ok: true, matchCount: 1, sessionId: id, action: 'bolder', count: 3 } }, + }); + // A superseded Go from another page: refused, naming the serving session, nothing journaled. + const refused = await event('dddddddd', 'tab-b'); + assert.equal(refused.status, 409); + const body = await refused.json(); + assert.equal(body.error, 'agent_target_already_served'); + assert.equal(body.sessionId, 'cccccccc'); + assert.ok(!existsSync(join(tmp, '.impeccable/live/sessions/dddddddd.jsonl')), 'a refused Go journals nothing'); + // The answering session's own event is welcome. + assert.equal((await event('cccccccc', 'tab-a')).status, 200); + } finally { + tabA.close(); + } + }); + it('prefers busy over no_match, so the agent retries when the right page is mid-session', async () => { const tabA = await openSseClient(server, { clientId: 'tab-a' }); const tabB = await openSseClient(server, { clientId: 'tab-b' }); diff --git a/tests/live-browser-source.test.mjs b/tests/live-browser-source.test.mjs index b1b220010..cd97352f4 100644 --- a/tests/live-browser-source.test.mjs +++ b/tests/live-browser-source.test.mjs @@ -872,8 +872,13 @@ describe('live-browser source contracts', () => { ); assert.match( SOURCE, - /if \(agentTargetForGo\) \{[\s\S]{0,600}?basePayload\.agentTarget = \{[\s\S]{0,300}?sessionId: currentSessionId/, - 'handleGo attaches the agent target with the session it minted', + /if \(agentTargetForGo\) \{[\s\S]{0,600}?basePayload\.agentTarget = \{\s*targetId: agentTargetForGo\.targetId,\s*clientId: AGENT_TARGET_CLIENT_ID,[\s\S]{0,300}?sessionId: currentSessionId/, + 'handleGo attaches the agent target with this page\'s client id and the session it minted, so the helper can tell a superseded Go from the serving one', + ); + assert.match( + SOURCE, + /body\.error === 'agent_target_already_served' && msg\.type === 'generate'\s*&& msg\.id && msg\.id === currentSessionId\) \{\s*abandonSupersededGo\(msg\.id\);\s*return null;/, + 'a Go the helper refused as already served drops this page\'s local session instead of leaving it generating for nothing', ); assert.match( SOURCE, From 76db41821296b4da14af3c60c520ed9c17c293f5 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Thu, 10 Sep 2026 01:15:48 +0500 Subject: [PATCH 16/42] Address review: every answered agent target fences a late Go Only targets answered with a session were fenced against a delayed generate event. A request that timed out (or ended in another failure verdict the CLI already reported) was simply forgotten, so a Go whose capture outlasted the timeout still opened a session nobody was told about. `resolve_agent_target` now records every terminal resolution, with the answering session when the verdict carried one, and `agent_target_refusal` refuses a generate event for any answered target unless it comes from the answering session itself. The browser_timeout instructions no longer send the agent to live-status for a session that can no longer start. The overlay's refusal toast covers both causes. Tests: a Rust integration case and a Node protocol case (claim, time out, late Go refused with 409 and nothing journaled), a unit test for the timeout instruction; contract doc updated. Written with AI assistance (Claude). Co-Authored-By: Claude Fable 5 --- crates/cli/tests/agent_target.rs | 25 +++++++++++- crates/live/src/live_generate.rs | 11 ++++- crates/live/src/live_server.rs | 14 ++++--- crates/live/src/server_state.rs | 70 ++++++++++++++++++++------------ docs/CLI-CONTRACT.md | 2 +- skill/scripts/live-browser.js | 11 ++--- tests/live-agent-target.test.mjs | 29 +++++++++++++ 7 files changed, 123 insertions(+), 39 deletions(-) diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs index 43fe008c0..568c82b7b 100644 --- a/crates/cli/tests/agent_target.rs +++ b/crates/cli/tests/agent_target.rs @@ -145,7 +145,10 @@ impl Server { .spawn() .expect("spawn live-server"); let pid_file = dir.join(".impeccable/live/server.json"); - assert!(wait_for(&pid_file, 10), "server pid file never appeared"); + // Sixteen servers spawn at once under the default test parallelism; a + // loaded machine has taken more than ten seconds to write the first + // pid file. + assert!(wait_for(&pid_file, 30), "server pid file never appeared"); let info: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&pid_file).unwrap()).unwrap(); let port = info["port"].as_u64().expect("port") as u16; @@ -559,3 +562,23 @@ fn agent_target_welcomes_the_generate_event_of_the_session_that_answered() { assert_eq!(status, 200, "{body}"); assert!(s.dir.join(".impeccable/live/sessions/cccccccc.jsonl").exists()); } + +#[test] +fn agent_target_fences_a_generate_event_that_lands_after_the_timeout() { + let s = Server::start("fenced"); + let mut a = Overlay::connect(s.port, &s.token, "tab-a"); + a.next(|m| m["type"] == "connected"); + let held = s.hold(serde_json::json!({})); + let target_id = a.next(|m| m["type"] == "agent_target")["targetId"].as_str().unwrap().to_string(); + assert_eq!(s.claim(&target_id, "tab-a", true)["granted"], serde_json::json!(true)); + // The holder never answers: the request times out (400ms) and the CLI + // reports it. Its Go lands after that: refused, nothing journaled, so + // no session exists that the agent was never told about. + let (_, verdict) = held.join().unwrap(); + assert_eq!(verdict["error"], serde_json::json!("browser_timeout"), "{verdict}"); + let (status, body) = post_json(s.port, "/events", generate_event_for(&s, &target_id, "eeeeeeee", "tab-a")); + assert_eq!(status, 409, "{body}"); + assert_eq!(body["error"], serde_json::json!("agent_target_already_served")); + assert!(body.get("sessionId").is_none(), "{body}"); + assert!(!s.dir.join(".impeccable/live/sessions/eeeeeeee.jsonl").exists()); +} diff --git a/crates/live/src/live_generate.rs b/crates/live/src/live_generate.rs index 5869d2d8c..6e9702db1 100644 --- a/crates/live/src/live_generate.rs +++ b/crates/live/src/live_generate.rs @@ -126,7 +126,7 @@ fn instructions_for(result: &Map, self_cmd: &str) -> Option "No page with the live overlay is connected. Open the app URL that serves a pageFiles entry yourself with your harness browser tool, then rerun this command. Only when no browser tool exists: give the user the URL and rerun with --wait-for-browser 120000 so the command fires as soon as they open the page.".to_string(), - "browser_timeout" => format!("The overlay did not answer in time. The page may be mid-reload: run {} live-status to check whether a session started anyway, reload the app page, then rerun this command.", self_cmd), + "browser_timeout" => "The overlay did not answer in time, and no session was started for this request (a Go that lands late is refused). The page may be mid-reload: reload the app page, then rerun this command.".to_string(), "invalid_selector" => "The selector is not valid CSS. Fix the selector syntax and rerun.".to_string(), "no_match" => { if n("rawMatchCount") > 0 { @@ -366,6 +366,15 @@ mod tests { m } + #[test] + fn timeout_instructions_promise_no_stray_session() { + let mut m = Map::new(); + m.insert("ok".into(), json!(false)); + m.insert("error".into(), json!("browser_timeout")); + let text = instructions_for(&m, "impeccable").unwrap(); + assert!(text.contains("no session was started"), "{text}"); + } + #[test] fn busy_instructions_tell_the_agent_whose_session_is_in_the_way() { let own = instructions_for(&busy("agent_target_in_flight"), "impeccable").unwrap(); diff --git a/crates/live/src/live_server.rs b/crates/live/src/live_server.rs index bd5bf32ce..9b82cc99d 100644 --- a/crates/live/src/live_server.rs +++ b/crates/live/src/live_server.rs @@ -184,7 +184,7 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { next_apply_timer_gen: 0, pending_agent_targets: Vec::new(), next_agent_target_timer_gen: 0, - served_agent_targets: Vec::new(), + resolved_agent_targets: Vec::new(), shutting_down: false, cleaned_up: false, log_tx, @@ -1240,17 +1240,19 @@ fn handle_events_post( } } if let Some(envelope) = agent_target.as_ref().and_then(Value::as_object) { - if let Some(served) = st.agent_target_served_elsewhere(envelope, id_str.as_deref()) { + if let Some(refusal) = st.agent_target_refusal(envelope, id_str.as_deref()) { // A superseded Go: this page's lease lapsed while it was still - // capturing and another page served the request. Journal - // nothing, so one request never gets two sessions. + // capturing and another page served the request, or the + // request was already answered (a timeout or a failure the CLI + // has reported). Journal nothing, so one request never gets a + // second session, or a session nobody was told about. drop(st); let mut body = json!({ "error": "agent_target_already_served", "targetId": envelope.get("targetId").cloned().unwrap_or(Value::Null), }); - if !served.is_empty() { - body["sessionId"] = Value::String(served); + if let Some(sid) = refusal.session_id { + body["sessionId"] = Value::String(sid); } respond(stream, cors, json_res(409, body)); return; diff --git a/crates/live/src/server_state.rs b/crates/live/src/server_state.rs index 168c1d5a5..393bddb1c 100644 --- a/crates/live/src/server_state.rs +++ b/crates/live/src/server_state.rs @@ -45,6 +45,13 @@ pub struct SseClient { } /// One overlay's roll-call report on an agent target: its busy state and why. +/// A generate event refused because its agent target is spoken for; see +/// `ServerState::agent_target_refusal`. +pub struct AgentTargetRefusal { + /// The session that answered the request, when the verdict carried one. + pub session_id: Option, +} + pub struct AgentTargetReport { pub client_id: String, pub state: Value, @@ -116,10 +123,12 @@ pub struct ServerState { /// Held-open agent targets keyed by targetId, in arrival order. pub pending_agent_targets: Vec<(String, AgentTargetPending)>, pub next_agent_target_timer_gen: u64, - /// Agent targets answered with a session, oldest first (bounded): a - /// generate event that names one of these under another session id is - /// a superseded Go and is refused. - pub served_agent_targets: Vec<(String, String)>, + /// Every agent target already answered, oldest first (bounded), with + /// the session that answered it when the verdict carried one: a + /// generate event that names one of these under another session id, or + /// after a verdict without a session (a timeout, a failure), is a + /// superseded Go and is refused. + pub resolved_agent_targets: Vec<(String, Option)>, pub last_poll_at: i64, pub timed_out_apply_ids: Vec<(String, TimedOutApply)>, pub next_poll_id: u64, @@ -820,30 +829,36 @@ impl ServerState { return false; }; let (_, pending) = self.pending_agent_targets.remove(pos); - if result.get("ok") == Some(&Value::Bool(true)) { - if let Some(sid) = result.get("sessionId").and_then(Value::as_str) { - self.served_agent_targets - .push((target_id.to_string(), sid.to_string())); - if self.served_agent_targets.len() > 64 { - self.served_agent_targets.remove(0); - } - } + let session = if result.get("ok") == Some(&Value::Bool(true)) { + result + .get("sessionId") + .and_then(Value::as_str) + .map(str::to_string) + } else { + None + }; + self.resolved_agent_targets + .push((target_id.to_string(), session)); + if self.resolved_agent_targets.len() > 64 { + self.resolved_agent_targets.remove(0); } let _ = pending.tx.send(result); true } - /// Whether a generate event naming `envelope.targetId`, sent by - /// `envelope.clientId` under `session_id`, is a superseded Go: the - /// target is still pending but another page holds a live lease on it - /// (this page's lease lapsed while it was capturing), or the request - /// was already answered with a different session. Returns the serving - /// session id, empty while the rival has not minted one yet. - pub fn agent_target_served_elsewhere( + /// Why a generate event naming `envelope.targetId`, sent by + /// `envelope.clientId` under `session_id`, must not open a session: + /// the target is still pending but another page holds a live lease on + /// it (this page's lease lapsed while it was capturing), or the request + /// was already answered, with a different session or with none (a + /// timeout or a failure verdict the CLI has already reported). None + /// when the event is welcome, which includes the answering session's + /// own event. + pub fn agent_target_refusal( &self, envelope: &Map, session_id: Option<&str>, - ) -> Option { + ) -> Option { let target_id = envelope.get("targetId").and_then(Value::as_str)?; let client_id = envelope .get("clientId") @@ -856,17 +871,22 @@ impl ServerState { { return match &pending.owner { Some(owner) if owner != client_id && pending.claimed_until > now_i64() => { - Some(String::new()) + Some(AgentTargetRefusal { session_id: None }) } _ => None, }; } - self.served_agent_targets + let (_, answered_by) = self + .resolved_agent_targets .iter() .rev() - .find(|(t, _)| t == target_id) - .filter(|(_, sid)| Some(sid.as_str()) != session_id) - .map(|(_, sid)| sid.clone()) + .find(|(t, _)| t == target_id)?; + if answered_by.as_deref() == session_id && session_id.is_some() { + return None; + } + Some(AgentTargetRefusal { + session_id: answered_by.clone(), + }) } /// Every connected overlay has declined: answer busy now, not at the diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index edba94c28..589612762 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1480,7 +1480,7 @@ Binds `127.0.0.1:PORT`. CORS: if request has `Origin` and (origin is loopback ht | `POST /manual-edit-discard?token=&pageUrl=` | 401 | see 10 | | `POST /manual-edit` | | 410 `{"error":"/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits."}` | | `POST /agent-target` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | Agent-initiated targeting (the `generate` command). Validation (400 `{"error":}`, messages verbatim): `agent_target: selector is required`, `agent_target: selector too long` (>1000 chars), `agent_target: invalid action (valid: )`, `agent_target: count must be 1-8`, `agent_target: text must be a string of at most 500 chars`, `agent_target: index must be a positive integer (1-based)`, `agent_target: prompt must be a string of at most 2000 chars`, `agent_target: dryRun must be a boolean`. No SSE client → 200 `{ok:false, error:'no_browser_connected'}`. Otherwise mint an 8-hex `targetId`, broadcast `agent_target` (see 6.2), and **hold the response** until `/agent-target-result` resolves it, every connected overlay has declined (busy roll call, see `/agent-target-claim`), or `IMPECCABLE_AGENT_TARGET_TIMEOUT_MS` (default 15000) elapses: busy verdict `{ok:false, error:'busy', state, reason}` from the first report when any report exists, else `{ok:false, error:'browser_timeout', timeoutMs}`. The held reply is 200 `{targetId, ...result}`; shutdown resolves every held request with `{ok:false, error:'server_stopping'}`. | -| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). A `generate` event on `POST /events` may carry `agentTarget: {targetId, result}`: once the event is accepted, the server resolves that pending target with `result` (the envelope is stripped before journaling and never reaches the poller), so a page that dies between Go and its result cannot leave the request pending for a second Go elsewhere; whichever of the event and the result post lands first answers. The envelope also carries `clientId`: a generate event naming a target that another page now holds (a live lease, this page's having lapsed while it captured) or that was already answered with a different session is refused with 409 `{"error":"agent_target_already_served", targetId, sessionId?}` and journals nothing, and the overlay drops that local session. | +| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). A `generate` event on `POST /events` may carry `agentTarget: {targetId, result}`: once the event is accepted, the server resolves that pending target with `result` (the envelope is stripped before journaling and never reaches the poller), so a page that dies between Go and its result cannot leave the request pending for a second Go elsewhere; whichever of the event and the result post lands first answers. The envelope also carries `clientId`: a generate event naming a target that another page now holds (a live lease, this page's having lapsed while it captured) or that was already answered, with a different session or with none (a timeout or a failure verdict the CLI has reported), is refused with 409 `{"error":"agent_target_already_served", targetId, sessionId?}` and journals nothing, and the overlay drops that local session; the answering session's own event is welcome. | | `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason, result?}` under `clientId` (replacing an earlier report; `result` is the overlay's resolution verdict when `reason` is `no_match`, i.e. its page cannot resolve the selector), release the lease if this client holds it, answer `{ok:true, granted:false, pending}` (`pending` false once the request resolved, so a declining overlay knows whether to keep watching for a change of its word), then complete the roll call when no owner holds the lease and reports ≥ connected overlays. Verdict precedence: a report whose `reason` is not `no_match` (a tab that could serve later) → `{ok:false, error:'busy', state, reason}` at once; when every report is `no_match` the roll call stays open for `IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS` (default 3000) after each overlay's first such report (a late reporter extends the grace by the full window; a page whose element mounts late keeps re-checking while its decline answers `pending:true`, an eligible claim drops its stale report, and the overlay declines rather than posting a result when the element is gone after its claim), then answers the first report's `result` (e.g. `no_match` with `rawMatchCount`, `invalid_selector`); the timeout uses the same precedence when any report exists. `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | | anything else | | 404 `Not found` | diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 0ecfee894..5a0b9b7cc 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -7736,9 +7736,10 @@ }).then(async res => { if (res.ok) return res; const body = await res.json().catch(() => ({})); - // The helper refused to open a second session for an agent target - // another page already served (this page's lease lapsed while it was - // capturing): drop the local session and hand the surface back. + // The helper refused to open a session for an agent target it has + // already answered (another page served it after this page's lease + // lapsed mid-capture, or the request timed out): drop the local + // session and hand the surface back. if (body.error === 'agent_target_already_served' && msg.type === 'generate' && msg.id && msg.id === currentSessionId) { abandonSupersededGo(msg.id); @@ -7767,10 +7768,10 @@ function abandonSupersededGo(sessionId) { if (sessionId !== currentSessionId) return; - console.warn('[impeccable] Another page already served this agent target; clearing session ' + sessionId + '.'); + console.warn('[impeccable] The helper already answered this agent target; clearing session ' + sessionId + '.'); markSessionHandled(); cleanup({ instantChrome: true }); - showToast('Another tab already served this request, so this session was cleared.', 6000); + showToast('The helper already answered this request, so this session was cleared. Pick an element to start fresh.', 6000); } let abandonedForeignSessionId = null; diff --git a/tests/live-agent-target.test.mjs b/tests/live-agent-target.test.mjs index b9e37a099..5d8c78ecf 100644 --- a/tests/live-agent-target.test.mjs +++ b/tests/live-agent-target.test.mjs @@ -808,6 +808,35 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA } }); + it('fences a generate event that lands after the request timed out, so no session the agent was never told about starts', async () => { + const tabA = await openSseClient(server, { clientId: 'tab-a' }); + try { + await tabA.next((m) => m.type === 'connected'); + const held = postJson(server, '/agent-target', { + token: server.token, selector: 'h1', action: 'bolder', count: 3, + }); + const pushed = await tabA.next((m) => m.type === 'agent_target'); + const claim = await (await postJson(server, '/agent-target-claim', { + token: server.token, targetId: pushed.targetId, clientId: 'tab-a', eligible: true, + })).json(); + assert.equal(claim.granted, true); + const verdict = await (await held).json(); + assert.equal(verdict.error, 'browser_timeout'); + const late = await postJson(server, '/events', { + token: server.token, type: 'generate', id: 'eeeeeeee', action: 'bolder', count: 3, pageUrl: '/', + element: { tagName: 'h1', outerHTML: '

Hero

' }, + agentTarget: { targetId: pushed.targetId, clientId: 'tab-a', result: { ok: true, matchCount: 1, sessionId: 'eeeeeeee', action: 'bolder', count: 3 } }, + }); + assert.equal(late.status, 409); + const body = await late.json(); + assert.equal(body.error, 'agent_target_already_served'); + assert.equal(body.sessionId, undefined); + assert.ok(!existsSync(join(tmp, '.impeccable/live/sessions/eeeeeeee.jsonl')), 'a fenced Go journals nothing'); + } finally { + tabA.close(); + } + }); + it('prefers busy over no_match, so the agent retries when the right page is mid-session', async () => { const tabA = await openSseClient(server, { clientId: 'tab-a' }); const tabB = await openSseClient(server, { clientId: 'tab-b' }); From d579ecb2f25bc0fe279f570707e9007f30fa596a Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Thu, 10 Sep 2026 01:26:50 +0500 Subject: [PATCH 17/42] Address review: a Go for a target the helper does not hold is refused The bounded record of answered targets evicted its oldest entry, and a generate event naming an unrecognized target was admitted, so a Go delayed past enough later resolutions could still open a session for a request the CLI had reported as failed. The admission rule is now positive: a generate event naming an agent target is welcome only while that target is pending without a rival lease, or when it comes from the session that answered it. Unknown targets, evicted or never issued, are refused like any other superseded Go, so eviction can never reopen a request. The record keeps 256 entries for the answering session's sake. Tests: a Rust integration case and a Node protocol case (an envelope naming an unheld target is refused and journals nothing; the same event without an envelope is an ordinary Go); contract doc updated. Written with AI assistance (Claude). Co-Authored-By: Claude Fable 5 --- crates/cli/tests/agent_target.rs | 17 +++++++++++++++++ crates/live/src/server_state.rs | 28 +++++++++++++++++----------- docs/CLI-CONTRACT.md | 2 +- tests/live-agent-target.test.mjs | 14 ++++++++++++++ 4 files changed, 49 insertions(+), 12 deletions(-) diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs index 568c82b7b..4bc8ed422 100644 --- a/crates/cli/tests/agent_target.rs +++ b/crates/cli/tests/agent_target.rs @@ -582,3 +582,20 @@ fn agent_target_fences_a_generate_event_that_lands_after_the_timeout() { assert!(body.get("sessionId").is_none(), "{body}"); assert!(!s.dir.join(".impeccable/live/sessions/eeeeeeee.jsonl").exists()); } + +#[test] +fn agent_target_refuses_a_generate_event_for_a_target_it_never_held() { + // Unknown means refused: a target this helper never issued, or one + // evicted from its bounded record, can never be reopened by a late Go. + let s = Server::start("unknown-target"); + let (status, body) = post_json(s.port, "/events", generate_event_for(&s, "0badf00d", "ffffffff", "tab-a")); + assert_eq!(status, 409, "{body}"); + assert_eq!(body["error"], serde_json::json!("agent_target_already_served")); + assert!(body.get("sessionId").is_none(), "{body}"); + assert!(!s.dir.join(".impeccable/live/sessions/ffffffff.jsonl").exists()); + // Without an envelope the same event is an ordinary Go. + let mut plain = generate_event_for(&s, "0badf00d", "ffffffff", "tab-a"); + plain.as_object_mut().unwrap().remove("agentTarget"); + let (status, _) = post_json(s.port, "/events", plain); + assert_eq!(status, 200); +} diff --git a/crates/live/src/server_state.rs b/crates/live/src/server_state.rs index 393bddb1c..2a4787a0c 100644 --- a/crates/live/src/server_state.rs +++ b/crates/live/src/server_state.rs @@ -124,10 +124,11 @@ pub struct ServerState { pub pending_agent_targets: Vec<(String, AgentTargetPending)>, pub next_agent_target_timer_gen: u64, /// Every agent target already answered, oldest first (bounded), with - /// the session that answered it when the verdict carried one: a - /// generate event that names one of these under another session id, or - /// after a verdict without a session (a timeout, a failure), is a - /// superseded Go and is refused. + /// the session that answered it when the verdict carried one. A + /// generate event naming a target is welcome only while that target is + /// pending without a rival lease, or when it comes from the session + /// that answered it; anything else, including a target this record no + /// longer holds, is refused, so eviction can never reopen a request. pub resolved_agent_targets: Vec<(String, Option)>, pub last_poll_at: i64, pub timed_out_apply_ids: Vec<(String, TimedOutApply)>, @@ -839,7 +840,7 @@ impl ServerState { }; self.resolved_agent_targets .push((target_id.to_string(), session)); - if self.resolved_agent_targets.len() > 64 { + if self.resolved_agent_targets.len() > 256 { self.resolved_agent_targets.remove(0); } let _ = pending.tx.send(result); @@ -849,11 +850,13 @@ impl ServerState { /// Why a generate event naming `envelope.targetId`, sent by /// `envelope.clientId` under `session_id`, must not open a session: /// the target is still pending but another page holds a live lease on - /// it (this page's lease lapsed while it was capturing), or the request + /// it (this page's lease lapsed while it was capturing); the request /// was already answered, with a different session or with none (a - /// timeout or a failure verdict the CLI has already reported). None - /// when the event is welcome, which includes the answering session's - /// own event. + /// timeout or a failure verdict the CLI has already reported); or the + /// helper neither holds nor remembers the target (never issued here, or + /// long since evicted from the bounded record). None only when the + /// event is welcome: a pending target without a rival, or the + /// answering session's own event. pub fn agent_target_refusal( &self, envelope: &Map, @@ -876,11 +879,14 @@ impl ServerState { _ => None, }; } - let (_, answered_by) = self + let Some((_, answered_by)) = self .resolved_agent_targets .iter() .rev() - .find(|(t, _)| t == target_id)?; + .find(|(t, _)| t == target_id) + else { + return Some(AgentTargetRefusal { session_id: None }); + }; if answered_by.as_deref() == session_id && session_id.is_some() { return None; } diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 589612762..d5d4c08aa 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1480,7 +1480,7 @@ Binds `127.0.0.1:PORT`. CORS: if request has `Origin` and (origin is loopback ht | `POST /manual-edit-discard?token=&pageUrl=` | 401 | see 10 | | `POST /manual-edit` | | 410 `{"error":"/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits."}` | | `POST /agent-target` | body JSON `token` mismatch → 401 `{"error":"Unauthorized"}`; invalid JSON → 400 `{"error":"Invalid JSON"}` | Agent-initiated targeting (the `generate` command). Validation (400 `{"error":}`, messages verbatim): `agent_target: selector is required`, `agent_target: selector too long` (>1000 chars), `agent_target: invalid action (valid: )`, `agent_target: count must be 1-8`, `agent_target: text must be a string of at most 500 chars`, `agent_target: index must be a positive integer (1-based)`, `agent_target: prompt must be a string of at most 2000 chars`, `agent_target: dryRun must be a boolean`. No SSE client → 200 `{ok:false, error:'no_browser_connected'}`. Otherwise mint an 8-hex `targetId`, broadcast `agent_target` (see 6.2), and **hold the response** until `/agent-target-result` resolves it, every connected overlay has declined (busy roll call, see `/agent-target-claim`), or `IMPECCABLE_AGENT_TARGET_TIMEOUT_MS` (default 15000) elapses: busy verdict `{ok:false, error:'busy', state, reason}` from the first report when any report exists, else `{ok:false, error:'browser_timeout', timeoutMs}`. The held reply is 200 `{targetId, ...result}`; shutdown resolves every held request with `{ok:false, error:'server_stopping'}`. | -| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). A `generate` event on `POST /events` may carry `agentTarget: {targetId, result}`: once the event is accepted, the server resolves that pending target with `result` (the envelope is stripped before journaling and never reaches the poller), so a page that dies between Go and its result cannot leave the request pending for a second Go elsewhere; whichever of the event and the result post lands first answers. The envelope also carries `clientId`: a generate event naming a target that another page now holds (a live lease, this page's having lapsed while it captured) or that was already answered, with a different session or with none (a timeout or a failure verdict the CLI has reported), is refused with 409 `{"error":"agent_target_already_served", targetId, sessionId?}` and journals nothing, and the overlay drops that local session; the answering session's own event is welcome. | +| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). A `generate` event on `POST /events` may carry `agentTarget: {targetId, result}`: once the event is accepted, the server resolves that pending target with `result` (the envelope is stripped before journaling and never reaches the poller), so a page that dies between Go and its result cannot leave the request pending for a second Go elsewhere; whichever of the event and the result post lands first answers. The envelope also carries `clientId`: a generate event naming a target that another page now holds (a live lease, this page's having lapsed while it captured) or that was already answered, with a different session or with none (a timeout or a failure verdict the CLI has reported), or that the helper neither holds nor remembers (never issued by it, or evicted from its bounded record of answered targets), is refused with 409 `{"error":"agent_target_already_served", targetId, sessionId?}` and journals nothing, and the overlay drops that local session; the answering session's own event is welcome. | | `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason, result?}` under `clientId` (replacing an earlier report; `result` is the overlay's resolution verdict when `reason` is `no_match`, i.e. its page cannot resolve the selector), release the lease if this client holds it, answer `{ok:true, granted:false, pending}` (`pending` false once the request resolved, so a declining overlay knows whether to keep watching for a change of its word), then complete the roll call when no owner holds the lease and reports ≥ connected overlays. Verdict precedence: a report whose `reason` is not `no_match` (a tab that could serve later) → `{ok:false, error:'busy', state, reason}` at once; when every report is `no_match` the roll call stays open for `IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS` (default 3000) after each overlay's first such report (a late reporter extends the grace by the full window; a page whose element mounts late keeps re-checking while its decline answers `pending:true`, an eligible claim drops its stale report, and the overlay declines rather than posting a result when the element is gone after its claim), then answers the first report's `result` (e.g. `no_match` with `rawMatchCount`, `invalid_selector`); the timeout uses the same precedence when any report exists. `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | | anything else | | 404 `Not found` | diff --git a/tests/live-agent-target.test.mjs b/tests/live-agent-target.test.mjs index 5d8c78ecf..4bf529ddd 100644 --- a/tests/live-agent-target.test.mjs +++ b/tests/live-agent-target.test.mjs @@ -837,6 +837,20 @@ describe('POST /agent-target', { skip: ENGINE_BIN ? false : ENGINE_MISSING_MESSA } }); + it('refuses a generate event naming a target the helper never held, so eviction can never reopen a request', async () => { + const event = (agentTarget) => postJson(server, '/events', { + token: server.token, type: 'generate', id: 'ffffffff', action: 'bolder', count: 3, pageUrl: '/', + element: { tagName: 'h1', outerHTML: '

Hero

' }, + ...(agentTarget ? { agentTarget } : {}), + }); + const refused = await event({ targetId: '0badf00d', clientId: 'tab-a', result: { ok: true, sessionId: 'ffffffff' } }); + assert.equal(refused.status, 409); + assert.equal((await refused.json()).error, 'agent_target_already_served'); + assert.ok(!existsSync(join(tmp, '.impeccable/live/sessions/ffffffff.jsonl')), 'nothing journaled'); + // Without an envelope the same event is an ordinary Go. + assert.equal((await event(null)).status, 200); + }); + it('prefers busy over no_match, so the agent retries when the right page is mid-session', async () => { const tabA = await openSseClient(server, { clientId: 'tab-a' }); const tabB = await openSseClient(server, { clientId: 'tab-b' }); From 1220f26d08b85552e28816acfec87514c55c4692 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Thu, 10 Sep 2026 06:06:18 +0500 Subject: [PATCH 18/42] Make the generate lane snappy: self-contained playbook, fast-path poll instructions The maintainer's field run took five and a half minutes from the prompt to variants on screen. Two baseline runs on the same repo reproduced it (356 s mean): 68 KB of skill text read before the first variant (a 36 KB live.md among it), six to ten tool calls spent finding the dev URL and the selector, 9 to 10 KB of variants carrying tune knobs, and a document read plus a detect pass after the accept. generate.md is now the whole contract for the lane and never sends the agent to live.md, craft-floor.md, or the action reference on the happy path; the floors are inlined. The engine carries the rest: a generate started by live-generate is journaled and queued with origin "agent", and its poll instructions hand out the fast path (identity from the event's computed styles and custom properties, the action's three dimensions, no knobs unless asked, one edit, reply done) instead of the interactive planning pointer. `impeccable live --allow-missing-context` boots without PRODUCT.md or DESIGN.md, naming what is missing, so the lane never falls into the init interview; the boot also reports devUrl, the origin whose page carries the injected tag, so the agent opens the page instead of reading terminals. Accept is a bake and live-complete is its verification: no detect pass, no document read. Three trimmed runs (one without any context files) averaged 179 s from prompt to variants, 21 tool calls and 106k tokens against the baseline's 356 s, 30 tool calls and 144k tokens; the accept bake went from 67 s to 41 s. Method and numbers: tmp/questionaire/plan41-field-tests/SNAPPY-REPORT.md in the maintainer's checkout. Tests: dev_url probe unit tests, a fast-path instructions unit test, the origin marker in the protocol suite, and tests/live-boot-fastpath.test.mjs (flag, contextMissing, devUrl through a stand-in dev server); contract doc updated. Written with AI assistance (Claude). Co-Authored-By: Claude Fable 5 --- crates/cli/tests/agent_target.rs | 6 + crates/live/src/dev_url.rs | 124 ++++++++++++++++++ crates/live/src/instructions.rs | 81 ++++++++++++ crates/live/src/lib.rs | 1 + crates/live/src/live_boot.rs | 32 ++++- crates/live/src/live_generate.rs | 2 +- crates/live/src/live_server.rs | 9 ++ docs/CLI-CONTRACT.md | 6 +- scripts/test-suites.mjs | 2 + skill/reference/generate.md | 92 +++++++++---- tests/live-agent-target.test.mjs | 2 + tests/live-boot-fastpath.test.mjs | 86 ++++++++++++ tests/oracle/golden/live-boot-full-cycle.json | 2 +- 13 files changed, 411 insertions(+), 34 deletions(-) create mode 100644 crates/live/src/dev_url.rs create mode 100644 tests/live-boot-fastpath.test.mjs diff --git a/crates/cli/tests/agent_target.rs b/crates/cli/tests/agent_target.rs index 4bc8ed422..63b796820 100644 --- a/crates/cli/tests/agent_target.rs +++ b/crates/cli/tests/agent_target.rs @@ -124,8 +124,14 @@ struct Server { token: String, } +/// Server spawns are serialized: seventeen binaries starting at once on a +/// loaded machine have missed even a 30 s pid-file wait, while the tests +/// themselves still run in parallel once their server is up. +static START_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + impl Server { fn start(tag: &str) -> Server { + let _serialized = START_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let dir = std::env::temp_dir().join(format!("impeccable-agent-target-{}-{}", tag, std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(dir.join(".impeccable/live")).unwrap(); diff --git a/crates/live/src/dev_url.rs b/crates/live/src/dev_url.rs new file mode 100644 index 000000000..4e1408dfb --- /dev/null +++ b/crates/live/src/dev_url.rs @@ -0,0 +1,124 @@ +//! Find the dev server that is serving this app right now: the page that +//! carries our injected `live.js?token=` tag is ours, whatever port +//! it answers on. Saves the agent a terminal-reading detour before it can +//! open the page. + +use std::io::{Read, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +/// Ports worth a knock when nothing narrows the search: Vite, Next, Astro, +/// SvelteKit, Nuxt, CRA, Angular, and the usual static servers. +const DEFAULT_PORTS: &[u16] = &[5173, 3000, 4321, 8080, 4173, 3001, 5174, 8000, 4200, 5000, 1234]; + +/// Candidate origins, in probe order. `IMPECCABLE_DEV_URL_CANDIDATES` +/// (comma-separated) replaces the default list, for tests and unusual hosts. +pub fn candidates(env_override: Option<&str>) -> Vec { + if let Some(list) = env_override { + return list + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.trim_end_matches('/').to_string() + "/") + .collect(); + } + let mut out = Vec::new(); + for port in DEFAULT_PORTS { + out.push(format!("http://127.0.0.1:{}/", port)); + out.push(format!("http://localhost:{}/", port)); + } + out +} + +/// The first candidate whose document contains our tag, probed in parallel +/// with short timeouts so a full miss costs well under a second. +pub fn probe(candidates: &[String], token: &str) -> Option { + let needle = format!("live.js?token={}", token); + let hits: Vec> = std::thread::scope(|scope| { + let handles: Vec<_> = candidates + .iter() + .map(|url| { + let needle = needle.clone(); + scope.spawn(move || fetch_root(url).filter(|body| body.contains(&needle)).map(|_| url.clone())) + }) + .collect(); + handles.into_iter().map(|h| h.join().unwrap_or(None)).collect() + }); + hits.into_iter().flatten().next() +} + +/// A minimal HTTP/1.0 GET of `/`; returns the response body on any 2xx. +fn fetch_root(url: &str) -> Option { + let rest = url.strip_prefix("http://")?; + let host_port = rest.split('/').next()?; + let (host, port) = match host_port.rsplit_once(':') { + Some((h, p)) => (h, p.parse::().ok()?), + None => (host_port, 80), + }; + let addr = (host, port).to_socket_addrs().ok()?.next()?; + let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(300)).ok()?; + stream.set_read_timeout(Some(Duration::from_millis(1500))).ok()?; + stream.set_write_timeout(Some(Duration::from_millis(300))).ok()?; + stream + .write_all(format!("GET / HTTP/1.0\r\nHost: {}\r\nConnection: close\r\n\r\n", host_port).as_bytes()) + .ok()?; + let mut raw = Vec::new(); + let mut buf = [0u8; 8192]; + while raw.len() < 512 * 1024 { + match stream.read(&mut buf) { + Ok(0) => break, + Ok(n) => raw.extend_from_slice(&buf[..n]), + Err(_) => break, + } + } + let text = String::from_utf8_lossy(&raw).into_owned(); + let status_ok = text + .lines() + .next() + .map(|l| l.split_whitespace().nth(1).map(|c| c.starts_with('2')).unwrap_or(false)) + .unwrap_or(false); + if !status_ok { + return None; + } + Some(text.split_once("\r\n\r\n").map(|(_, b)| b.to_string()).unwrap_or(text)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + fn serve_once(body: &'static str) -> String { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + for _ in 0..2 { + if let Ok((mut s, _)) = listener.accept() { + let mut buf = [0u8; 1024]; + let _ = s.read(&mut buf); + let _ = s.write_all( + format!("HTTP/1.0 200 OK\r\nContent-Type: text/html\r\n\r\n{}", body).as_bytes(), + ); + } + } + }); + format!("http://127.0.0.1:{}/", port) + } + + #[test] + fn finds_the_origin_that_serves_our_tag() { + let ours = serve_once(""); + let theirs = serve_once(""); + let dead = "http://127.0.0.1:1/".to_string(); + let found = probe(&[dead, theirs.clone(), ours.clone()], "abc-123"); + assert_eq!(found.as_deref(), Some(ours.as_str())); + assert_eq!(probe(&[theirs], "abc-123"), None); + } + + #[test] + fn env_override_replaces_the_default_list() { + let c = candidates(Some("http://localhost:9999, http://127.0.0.1:7777/")); + assert_eq!(c, vec!["http://localhost:9999/".to_string(), "http://127.0.0.1:7777/".to_string()]); + assert!(candidates(None).iter().any(|u| u == "http://127.0.0.1:5173/")); + } +} diff --git a/crates/live/src/instructions.rs b/crates/live/src/instructions.rs index 699846693..358f33bb8 100644 --- a/crates/live/src/instructions.rs +++ b/crates/live/src/instructions.rs @@ -23,6 +23,49 @@ use serde_json::{Map, Value}; const PLAN_POINTER: &str = "Plan per live.md section 4: extract the identity lock, pick default vs departure mode, commit each variant to a DIFFERENT primary axis, squint-test the trio. Size parameter knobs per section 7 budgets."; +/// The three dimensions an agent-initiated generate varies for each action: +/// one per variant, so the trio reads as the same brand at three angles. +fn action_axes(action: &str) -> &'static str { + match action { + "bolder" => "scale (bigger type and tighter hierarchy) / saturation (commit the accent color) / structure (a stronger composition)", + "quieter" => "color (pull the accent back) / ornament (fewer decorations) / spacing (more air, softer edges)", + "distill" => "visual noise / redundant content / nested structure, one class of excess removed per variant", + "polish" => "rhythm / hierarchy / micro-details", + "typeset" => "a different pairing AND scale ratio per variant, within the available faces", + "colorize" => "a different hue family per variant, with its own chroma and contrast strategy", + "layout" => "three different structural arrangements, not spacing tweaks", + "adapt" => "mobile-first / tablet / desktop-or-print", + "animate" => "cascade stagger / clip wipe / scale-and-focus", + "delight" => "micro-interaction / typographic surprise / illustrated accent", + "overdrive" => "a different convention broken per variant: scale / structure / motion", + _ => "hierarchy / color strategy / density", + } +} + +/// What the poll tells the handler of a generate the agent itself started +/// (`origin: "agent"`): the user asked for variants to choose from, fast. +fn fast_path_instructions(event: &Map) -> String { + let action = event + .get("action") + .and_then(Value::as_str) + .filter(|a| !a.is_empty()) + .unwrap_or("impeccable"); + let count = js_str(event.get("count")); + let prompt = event + .get("freeformPrompt") + .and_then(Value::as_str) + .filter(|p| !p.trim().is_empty()) + .map(|p| format!(" The user's prompt narrows every variant: \"{}\".", slice16(p, 200))) + .unwrap_or_default(); + format!( + "Fast path (the user asked for {count} \"{action}\" variants to choose from, and is watching): do not read live.md, craft-floor.md, PRODUCT.md, or DESIGN.md now; the boot already handed you any design context, and this event carries element.computedStyles, element.cssCustomProperties, and element.parentContext. Lock the identity in ONE sentence from those (real colors, faces, corners, borders, shadows), then write {count} variants that each amplify a DIFFERENT dimension for {action}: {axes}. Keep the copy verbatim; no new fonts or hues beyond what the page already uses unless the prompt asks. No parameter knobs (no data-impeccable-params) unless the prompt asks for something tunable. Floors: body text contrast 4.5:1 or better, no text under 12px, controls at least 40px tall, focus states kept.{prompt}", + count = count, + action = action, + axes = action_axes(action), + prompt = prompt + ) +} + fn reply_cmd(self_cmd: &str, id: &str, rest: &str) -> String { format!("{} --reply {} {}", poll_cmd(self_cmd), id, rest) } @@ -201,13 +244,19 @@ fn generate_instructions(event: &Map, self_cmd: &str) -> String { )); } let action = event.get("action").filter(|a| truthy(Some(a))); + let agent_initiated = event.get("origin").and_then(Value::as_str) == Some("agent"); + if agent_initiated { + steps.push(fast_path_instructions(event)); + } match action { + Some(_) if agent_initiated => {} Some(a) if a.as_str() != Some("impeccable") => steps.push(format!( "Action is \"{}\": read reference/{}.md before planning; its MUST params are non-negotiable. {}", js_str(Some(a)), js_str(Some(a)), PLAN_POINTER )), + _ if agent_initiated => {} _ => steps.push(format!( "Freeform action: work from SKILL.md rules plus craft-floor.md; no sub-command file. {}", PLAN_POINTER @@ -385,3 +434,35 @@ fn accept_instructions(event: &Map, self_cmd: &str) -> String { prefix, file ) } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn generate_event(origin: Option<&str>) -> Map { + let mut m = Map::new(); + m.insert("type".into(), json!("generate")); + m.insert("id".into(), json!("ab12cd34")); + m.insert("action".into(), json!("bolder")); + m.insert("count".into(), json!(3)); + m.insert("element".into(), json!({ "tagName": "section", "id": "pricing", "classes": ["pricing"], "textContent": "Simple pricing" })); + if let Some(o) = origin { + m.insert("origin".into(), json!(o)); + } + m + } + + #[test] + fn an_agent_initiated_generate_gets_the_fast_path_not_the_planning_ceremony() { + let text = generate_instructions(&generate_event(Some("agent")), "impeccable"); + assert!(text.contains("Fast path"), "{text}"); + assert!(text.contains("scale (bigger type"), "{text}"); + assert!(text.contains("No parameter knobs"), "{text}"); + assert!(!text.contains("live.md section 4"), "{text}"); + assert!(!text.contains("read reference/bolder.md"), "{text}"); + let user = generate_instructions(&generate_event(None), "impeccable"); + assert!(user.contains("live.md section 4"), "{user}"); + assert!(!user.contains("Fast path"), "{user}"); + } +} diff --git a/crates/live/src/lib.rs b/crates/live/src/lib.rs index 0ee5f9407..ccf3ffea8 100644 --- a/crates/live/src/lib.rs +++ b/crates/live/src/lib.rs @@ -10,6 +10,7 @@ pub mod browser_assets; pub mod config; pub mod copy_edit_agent; pub mod design_md; +pub mod dev_url; pub mod event_validation; pub mod gitignore; pub mod inject; diff --git a/crates/live/src/live_boot.rs b/crates/live/src/live_boot.rs index f93ae8645..c7ddf1aca 100644 --- a/crates/live/src/live_boot.rs +++ b/crates/live/src/live_boot.rs @@ -118,7 +118,12 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { if design.is_none() { missing.push("DESIGN.md"); } - if !missing.is_empty() { + // `--allow-missing-context`: a caller that would rather start from the + // page than from an interview (the generate command) boots anyway; the + // payload names what is missing so the agent extracts the identity + // from the surface instead of running init or document mid-session. + let allow_missing_context = args.iter().any(|a| a == "--allow-missing-context"); + if !missing.is_empty() && !allow_missing_context { let payload = json!({ "ok": false, "error": "context_missing", @@ -260,10 +265,35 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { break; } let self_cmd = impeccable_context::provider::detect(&env, &cwd).self_cmd; + // 6. Which dev server is serving this app right now (the page carrying + // our tag), so the agent opens it without reading terminals. + let token_for_probe = match server_info.get("token") { + Some(Value::String(s)) => s.clone(), + _ => String::new(), + }; + let dev_url = if token_for_probe.is_empty() { + None + } else { + crate::dev_url::probe( + &crate::dev_url::candidates(env.get("IMPECCABLE_DEV_URL_CANDIDATES").map(String::as_str)), + &token_for_probe, + ) + }; + let context_note = if missing.is_empty() { + Value::Null + } else { + json!(format!( + "Booted without {} (--allow-missing-context). Extract the identity from the picked element's computed styles, CSS custom properties, and sibling styling; do not run init or document during this session, and do not ask for them.", + missing.join(" and ") + )) + }; let payload = json!({ "ok": true, "serverPort": server_info.get("port").cloned().unwrap_or(Value::Null), "serverToken": server_info.get("token").cloned().unwrap_or(Value::Null), + "devUrl": dev_url, + "contextMissing": missing, + "contextNote": context_note, "pageFiles": resolved_files, "liveConfigPath": check_result.get("path").cloned().unwrap_or(Value::Null), "configDrift": drift, diff --git a/crates/live/src/live_generate.rs b/crates/live/src/live_generate.rs index 6e9702db1..222e8a22d 100644 --- a/crates/live/src/live_generate.rs +++ b/crates/live/src/live_generate.rs @@ -120,7 +120,7 @@ fn instructions_for(result: &Map, self_cmd: &str) -> Option}`, messages verbatim): `agent_target: selector is required`, `agent_target: selector too long` (>1000 chars), `agent_target: invalid action (valid: )`, `agent_target: count must be 1-8`, `agent_target: text must be a string of at most 500 chars`, `agent_target: index must be a positive integer (1-based)`, `agent_target: prompt must be a string of at most 2000 chars`, `agent_target: dryRun must be a boolean`. No SSE client → 200 `{ok:false, error:'no_browser_connected'}`. Otherwise mint an 8-hex `targetId`, broadcast `agent_target` (see 6.2), and **hold the response** until `/agent-target-result` resolves it, every connected overlay has declined (busy roll call, see `/agent-target-claim`), or `IMPECCABLE_AGENT_TARGET_TIMEOUT_MS` (default 15000) elapses: busy verdict `{ok:false, error:'busy', state, reason}` from the first report when any report exists, else `{ok:false, error:'browser_timeout', timeoutMs}`. The held reply is 200 `{targetId, ...result}`; shutdown resolves every held request with `{ok:false, error:'server_stopping'}`. | -| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). A `generate` event on `POST /events` may carry `agentTarget: {targetId, result}`: once the event is accepted, the server resolves that pending target with `result` (the envelope is stripped before journaling and never reaches the poller), so a page that dies between Go and its result cannot leave the request pending for a second Go elsewhere; whichever of the event and the result post lands first answers. The envelope also carries `clientId`: a generate event naming a target that another page now holds (a live lease, this page's having lapsed while it captured) or that was already answered, with a different session or with none (a timeout or a failure verdict the CLI has reported), or that the helper neither holds nor remembers (never issued by it, or evicted from its bounded record of answered targets), is refused with 409 `{"error":"agent_target_already_served", targetId, sessionId?}` and journals nothing, and the overlay drops that local session; the answering session's own event is welcome. | +| `POST /agent-target-result` | 401 / 400 Invalid JSON | `targetId` (non-empty string) required else 400 `{"error":"agent_target_result: missing targetId"}`; the remaining body fields (minus `token`) resolve the held request; 200 `{ok:true, delivered:boolean}` (`delivered:false` when nothing awaits that id). A `generate` event on `POST /events` may carry `agentTarget: {targetId, result}`: once the event is accepted, the server resolves that pending target with `result` (the envelope is stripped before journaling and never reaches the poller), so a page that dies between Go and its result cannot leave the request pending for a second Go elsewhere; whichever of the event and the result post lands first answers. An accepted generate event carrying the envelope is journaled and queued with `origin: "agent"`, and `live-poll` renders that event's `_instructions` as the fast path (identity from the event's `element.computedStyles` / `cssCustomProperties` / `parentContext`, the action's three dimensions, no parameter knobs unless the prompt asks, one edit, reply done) instead of the interactive planning pointer and the action-reference read. The envelope also carries `clientId`: a generate event naming a target that another page now holds (a live lease, this page's having lapsed while it captured) or that was already answered, with a different session or with none (a timeout or a failure verdict the CLI has reported), or that the helper neither holds nor remembers (never issued by it, or evicted from its bounded record of answered targets), is refused with 409 `{"error":"agent_target_already_served", targetId, sessionId?}` and journals nothing, and the overlay drops that local session; the answering session's own event is welcome. | | `POST /agent-target-claim` | 401 / 400 Invalid JSON | `targetId` and `clientId` (non-empty strings) required else 400 `{"error":"agent_target_claim: missing targetId or clientId"}`. Roll call plus a first-wins lease, so exactly one overlay acts on a broadcast target. Unknown or resolved target → `{ok:true, granted:false, pending:false}` (ends a rescuer's retry loop). `eligible !== true` → record `{state, reason, result?}` under `clientId` (replacing an earlier report; `result` is the overlay's resolution verdict when `reason` is `no_match`, i.e. its page cannot resolve the selector), release the lease if this client holds it, answer `{ok:true, granted:false, pending}` (`pending` false once the request resolved, so a declining overlay knows whether to keep watching for a change of its word), then complete the roll call when no owner holds the lease and reports ≥ connected overlays. Verdict precedence: a report whose `reason` is not `no_match` (a tab that could serve later) → `{ok:false, error:'busy', state, reason}` at once; when every report is `no_match` the roll call stays open for `IMPECCABLE_AGENT_TARGET_RESOLVE_GRACE_MS` (default 3000) after each overlay's first such report (a late reporter extends the grace by the full window; a page whose element mounts late keeps re-checking while its decline answers `pending:true`, an eligible claim drops its stale report, and the overlay declines rather than posting a result when the element is gone after its claim), then answers the first report's `result` (e.g. `no_match` with `rawMatchCount`, `invalid_selector`); the timeout uses the same precedence when any report exists. `eligible === true` → drop this client's earlier report; `granted` when no owner, the same owner (renew), or the lease lapsed (`IMPECCABLE_AGENT_TARGET_CLAIM_LEASE_MS`, default 3000); answer `{ok:true, granted, pending:true}`. | | anything else | | 404 `Not found` | @@ -1727,13 +1727,13 @@ Conventions: every script's "run directly" guard is `process.argv[1]` ending wit - Flow & outputs (all pretty-printed JSON, 2 spaces, exit 0 unless noted): 1. Workspace monorepo selection (`resolveTargetSelection`, only when no target, cwd is a workspace/monorepo root with discoverable children): `{ok:false, error:'target_selection_required', targetPath:null, projectRoot, repoRoot, targetCandidates:[{name, path, targetExample, …context summary}], hint:'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.'}`. 2. `resolveRoots` selection → `{ok:false, error:'target_selection_required', targetCandidates:[{name,path}], hint:'Several apps with a dev-server config exist. Ask the user which one to use, then rerun with --target .'}`. - 3. Missing/unreadable/empty PRODUCT.md or DESIGN.md → `{ok:false, error:'context_missing', missing:['PRODUCT.md'?,'DESIGN.md'?], nextCommand:'init'|'document', targetPath, projectRoot, repoRoot, productPath:rel|null, designPath:rel|null}`. + 3. Missing/unreadable/empty PRODUCT.md or DESIGN.md → `{ok:false, error:'context_missing', missing:['PRODUCT.md'?,'DESIGN.md'?], nextCommand:'init'|'document', targetPath, projectRoot, repoRoot, productPath:rel|null, designPath:rel|null}`. With `--allow-missing-context` the boot continues instead: the success payload carries `contextMissing:[...]` (the same names; `[]` when nothing is missing) and `contextNote` (a sentence telling the agent to extract the identity from the page and never run init or document mid-session; `null` when nothing is missing), with `hasProduct`/`hasDesign` false and `product`/`design` null for the missing ones. 4. `writeRootsManifest(roots)`. 5. `node live-inject.mjs --check` (cwd appRoot, 15 s): not ok → print that JSON (`{ok:false,error:'config_missing'|'config_invalid',path,message?}` or `{ok:false,error:'check_failed',raw}`) + `targetPath, projectRoot, repoRoot`, exit 0. 6. Reuse server if `server.json` pid alive, else `node live-server.mjs --background`; failure → `{ok:false,error:'server_start_failed'}` exit 1. 7. `node live-inject.mjs --port P --token T`; not ok → `{ok:false,error:'inject_failed',detail:,serverPort}` exit 1. 8. Drift scan: `.html` files under `public, src, app, pages` (skipping ignored dirs/dot-dirs) not in resolved files and not user-excluded → `configDrift = {orphans:[≤20], orphanCount, hint:'N HTML file(s) exist but aren\'t in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".'}` else `null`. - 9. Success: `{ok:true, serverPort, serverToken, pageFiles:[…resolved], liveConfigPath, configDrift, targetPath, projectRoot:appRoot, repoRoot, roots:{manifest}, hasProduct:true, product:, productPath:rel, hasDesign:true, design:, designPath:rel, hasSurfaceBrief, surfaceBrief:, surfaceBriefPath:rel|null, _instructions:'Open the app URL that serves a pageFiles entry (never serverPort; that is the helper). Then start the poll loop per your harness policy in live.md and re-run node /live-poll.mjs immediately after every event or reply. Every event carries _instructions: follow them; they are the authoritative next step with real ids and paths filled in. A poll that is running is a poll you are SERVICING: never announce you are waiting and idle your turn; stay on the exec session until it returns an event, and never end a turn while a poll is outstanding.'}`. Surface brief resolved from `.impeccable/surfaces` under appRoot, contextRoot, repoRoot (first hit). + 9. Success: `{ok:true, serverPort, serverToken, devUrl, contextMissing, contextNote, pageFiles:[…resolved], liveConfigPath, configDrift, targetPath, projectRoot:appRoot, repoRoot, roots:{manifest}, hasProduct:true, product:, productPath:rel, hasDesign:true, design:, designPath:rel, hasSurfaceBrief, surfaceBrief:, surfaceBriefPath:rel|null, _instructions:'Open the app URL that serves a pageFiles entry (never serverPort; that is the helper). Then start the poll loop per your harness policy in live.md and re-run node /live-poll.mjs immediately after every event or reply. Every event carries _instructions: follow them; they are the authoritative next step with real ids and paths filled in. A poll that is running is a poll you are SERVICING: never announce you are waiting and idle your turn; stay on the exec session until it returns an event, and never end a turn while a poll is outstanding.'}`. Surface brief resolved from `.impeccable/surfaces` under appRoot, contextRoot, repoRoot (first hit). `devUrl` is the origin of the dev server serving this app right now, found by fetching `/` on the candidate origins (`http://127.0.0.1:

/` and `http://localhost:

/` for p in 5173, 3000, 4321, 8080, 4173, 3001, 5174, 8000, 4200, 5000, 1234, probed in parallel with sub-second timeouts; `IMPECCABLE_DEV_URL_CANDIDATES` replaces the list with a comma-separated one) and keeping the first whose document contains the injected `live.js?token=` tag; `null` when none does. - Tests: `tests/live-target-context.test.mjs`, `tests/live-roots.test.mjs`, `tests/live-e2e.test.mjs` (`session.liveBoot` for `appDir` fixtures), `tests/live-recovery-commands.test.mjs`. #### `live-server.mjs` -> `impeccable live-server` diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index b2246f8fe..95d371e27 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -141,6 +141,7 @@ export const SUITES = { /^tests\/live-e2e\//, /^tests\/lib\/engine-bin\.mjs$/, /^tests\/live-agent-target\.test\.mjs$/, + /^tests\/live-boot-fastpath\.test\.mjs$/, ], commands: [ { @@ -148,6 +149,7 @@ export const SUITES = { files: [ 'tests/live-reference.test.mjs', 'tests/live-agent-target.test.mjs', + 'tests/live-boot-fastpath.test.mjs', 'tests/live-browser-ignores.test.mjs', 'tests/live-browser-source.test.mjs', 'tests/live-e2e-agent-output.test.mjs', diff --git a/skill/reference/generate.md b/skill/reference/generate.md index 4746a108b..cc078272e 100644 --- a/skill/reference/generate.md +++ b/skill/reference/generate.md @@ -1,14 +1,17 @@ > **Additional context needed**: only the target element, when the request does not name one that resolves uniquely on the page. -Generate is a programmatic entry into live mode: the user names an element, a direction, and a count in one sentence, and you boot the live session, point the browser at the element, and the overlay scrolls to it, selects it, and fires the same Go a user click fires. Everything downstream is the standard live session. Read [live.md](live.md) in full now if you have not this session; this file is the entry ramp into its contract, and from Step 4 on you are inside it, with one deliberate divergence: Step 5 closes the session on its own once the accept lands, instead of staying open the way `live` does. +Generate is the fast lane into live mode: the user names an element, a direction, and a count in one sentence, and within a minute they are cycling through variants in their browser. You boot the helper, open the page, and hand the element to `impeccable live-generate`; the overlay scrolls to it, selects it, and fires the same Go a click fires. This file is the whole contract for that lane. **Do not read [live.md](live.md) for it**: every tool output carries `_instructions` with the next move for that exact situation, and they win over anything you remember. Open live.md only for a situation this file names as outside the lane. **Web only.** Live mode's browser overlay has no native equivalent; on `ios` / `android` / `adaptive` projects, decline this command and offer `bolder` or `quieter` on the source instead. -Three prohibitions cover the known ways this command goes wrong. Each names the tempting move first: +Speed is the product here. Every tool call before the variants land is a second the user spends staring at a selected element. The lane below is five commands and one edit; anything beyond it needs a reason from the output in front of you. This lane also replaces Setup step 3 for the preview edit: the floors craft-floor.md guards are written into Step 4, so do not open craft-floor.md, and read the action's reference only when Step 4 says so. -- The poll shows no generate event yet, and writing variants straight into source feels faster. **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go), and the server refuses events for any other id; a missing event is fixed in Step 2 or Step 3, never with a direct source edit. -- Handing the user a link to click feels polite. **Open the page yourself** (Step 2); a pasted link usually means no page ever connects. -- The design hook may flag the preview scaffolding you just published. **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; `impeccable live-complete` verifies the file once the accepted variant is permanent. Current hooks stand down on the markers themselves; older installed hooks may still nag. +Four prohibitions cover the known ways this command goes wrong: + +- **Never run init or document, and never ask for PRODUCT.md or DESIGN.md.** When they exist, the boot prints them and you use them. When they do not, the boot says so and you extract the identity from the page (Step 4). A missing file is never a reason to interview the user inside this command; offer `init` in one line after the session ends. +- **Never hand-write a variants wrapper or invent a session id.** Only the browser mints session ids (8 hex characters, at Go). A missing event is fixed in Step 2 or Step 3, never with a direct source edit. +- **Open the page yourself** (Step 2). A pasted link usually means no page ever connects. +- **Do not act on hook findings while live markers are in the file**, and do not restyle variants to appease them; `impeccable live-complete` verifies the file once the accepted variant is permanent. ## Step 1: Parse the request @@ -33,60 +36,93 @@ Three parts, all from the user's sentence: Done when you hold an action from the vocabulary, a count from 1 to 8, and the element description. -## Step 2: Boot live mode and open the page +## Step 2: Boot and open the page -Run the boot exactly as [live.md](live.md)'s Start section describes: +One command. Pass `--target` with the file that renders the element when the request or the project makes it obvious; skip it otherwise. Always pass `--allow-missing-context`: it lets the boot proceed when PRODUCT.md or DESIGN.md is absent and changes nothing when both exist. ```bash -{{scripts_path}}/impeccable live +{{scripts_path}}/impeccable live --target src/App.jsx --allow-missing-context ``` -**`config_missing` / `config_invalid`**: follow [live-setup.md](live-setup.md) first. +Read three fields of the output and nothing else: -Then open the app URL that serves a `pageFiles` entry (never `serverPort`; that is the helper, not the app): +- `product` / `design` (or `contextMissing` with a `contextNote`): the design context you have. Present means use it; missing means the page is the source of truth, per the note. Either way, continue. +- `devUrl`: the dev server that is serving this app right now. **Open it**: Cursor `browser_navigate`, any other harness its browser tool. `devUrl: null` means no dev server is serving the page yet: start the project's dev script in a background terminal (`npm run dev` or the framework's equivalent), open the URL it prints, and never kill or restart it afterwards. +- `pageFiles`: the page the helper injected into; the URL that serves it is the one to open (never `serverPort`, that is the helper). -- **Cursor**: `browser_navigate` to the URL now; do not skip it. -- **Any other harness with a browser tool**: open the URL with that tool. -- **No browser tool exists in this harness**: tell the user the exact URL to open, and pass `--wait-for-browser 120000` in Step 3 so the command fires the moment their page connects. +**No browser tool in this harness**: tell the user the exact URL in one line, and pass `--wait-for-browser 120000` in Step 3 so the command fires the moment their page connects. -Done when the boot printed `"ok": true` and a page with the overlay is connected, which Step 3 proves by answering anything other than `no_browser_connected`. +**`config_missing` / `config_invalid`**: follow [live-setup.md](live-setup.md) first, then rerun the boot. + +Done when the boot printed `"ok": true` and a page is open. You do not need to read `package.json`, the dev-server config, terminal logs, or the page source to get here. ## Step 3: Target the element -Derive the selector from project source, not from guesswork: an id first, then a unique class, then a landmark tag plus class. **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so scoped CSS restyles every instance at once. **Unsure the selector resolves uniquely**: probe with `--dry-run`; it resolves and reports without starting anything, and it works even mid-session. +One command. Derive the selector from what the user said and what you already know of the project: an id first, then a unique class, then a landmark tag plus class. **The request names a repeated component in plural** ("the pricing cards"): target the container that holds the set, so one scoped stylesheet restyles every instance. One read of the source file that renders the element is allowed when the selector is not obvious; `--dry-run` resolves and reports without starting anything when it is not certain. ```bash -{{scripts_path}}/impeccable live-generate --selector "section.pricing" --action bolder --count 3 +{{scripts_path}}/impeccable live-generate --selector "#pricing" --action bolder --count 3 ``` Flags: `--selector` (required), `--action`, `--count`, `--prompt`, `--text` (keep only matches whose visible text contains a snippet), `--index` (1-based pick among matches), `--dry-run`, `--wait-for-browser `. -Every verdict carries `_instructions` with the next move for that exact situation, with real values filled in; follow them over your recollection of this file. Two verdicts deserve naming because their fix sits outside the command: +Every verdict carries `_instructions`; follow them over your recollection of this file. Two deserve naming: - **`no_browser_connected`**: Step 2's page is not actually open; open it yourself, then rerun. -- **`ambiguous`**: the candidates are listed in the output; target their common container, or rerun with `--text ""` or `--index `. +- **`ambiguous`**: the candidates are listed; target their common container, or rerun with `--text ""` or `--index `. -Done when the verdict is `ok: true` with a `sessionId`: the browser has scrolled to the element, entered the picked state, and fired Go. +Done when the verdict is `ok: true` with a `sessionId`: the browser has scrolled to the element, selected it, and fired Go. ## Step 4: Generate -Start the poll loop per your harness policy in [live.md](live.md). The queued event for the returned `sessionId` is a standard `generate` event with the picked element's context and a preflighted scaffold; handle it exactly per live.md's Handle generate, which owns everything from planning to the done reply. +Start the poll. Harness policy: **Cursor** runs `{{scripts_path}}/impeccable live-poll` one-shot in a background terminal with notify on `"type":"(generate|accept|discard|variant_mount_failed|exit)"`, handles the event, replies, and restarts the poll; **Claude Code** runs it as a background task; **Codex** runs it one-shot in a yielded foreground exec session and services it; never pass a short `--timeout=`. -Then tell the user, in one line, where their variants are: *"Three [bolder] variants are live on [the pricing cards]: cycle with the floating bar's arrows, adjust the Tune knobs, and Accept the keeper."* +The first event is the `generate` for your `sessionId`, and its `_instructions` are the whole plan: the fast path names the identity sources (the event's `element.computedStyles`, `cssCustomProperties`, and `parentContext`, plus whatever the boot printed), the three dimensions your variants vary for this action, the no-knobs default, and the exact splice. Do it in ONE edit and reply done. Concretely: -**Publishing variants does not end the session.** Keep servicing the poll; accept, discard, and carbonize cleanup follow live.md unchanged, and the helper server stays up through the accept. Done when live.md's contract marks the event you handled complete and the poll is running again. +1. **Identity, one sentence, from the event.** Real colors, faces, corners, borders, shadows, and the layout topology on screen. DESIGN.md wins when the boot printed one. Never read PRODUCT.md, DESIGN.md, live.md, or craft-floor.md for this; never screenshot the page. +2. **The action's reference is optional.** Read `reference/.md` only when the prompt or the element makes the direction unclear; the `_instructions` already carry the action's three dimensions. +3. **Write the splice.** The event's `scaffold` tells you where: `sourceWritten: false` hands you `wrapperBlock` and the source range to replace (`replaceStartLine` to `replaceEndLine`); a written wrapper hands you `file` and `insertLine`. Either way, one edit lands the preview CSS plus all variants: -## Step 5: Close the session +```html + + +

+
+
+``` -Generate is a one-shot command; this is where it diverges from an open-ended `live` session. Once the accept (or discard) completes, wrap up without being asked: carbonize cleanup is done and `impeccable live-complete` printed `phase: "completed"` (a discard needs no cleanup), so kill your background poll and run live.md's Cleanup: + Rules that keep the browser mounting what you wrote: each variant div holds exactly ONE top-level element, same tag as the original, with the copy verbatim; first variant visible, the rest `display: none`; every `:scope` rule steps into a descendant (`:scope > .card`, never a bare `:scope`); use the `styleTag` and selector strategy from the event's `cssAuthoring` when it differs from the sketch above. **JSX / TSX**: wrap the `").unwrap()); + +/// Plan the bake, or say why it is not mechanical. `css_lines` is the whole +/// preview stylesheet (JSX template wrap already stripped), `restored` the +/// accepted variant at the wrapper's indentation, `source_after_unwrap` the +/// source file with the variant unwrapped (to find its own `\n{v1}{v2}{v3} {{/* impeccable-variants-end {s} */}}\n \n \n \n );\n}}\n", + s = SESSION, + v1 = variant("1", false), + v2 = variant("2", true), + v3 = variant("3", true) + ) + } + + fn project(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("impeccable-accept-bake-{}-{}", tag, std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("src")).unwrap(); + std::fs::create_dir_all(dir.join(".impeccable/live")).unwrap(); + std::fs::write(dir.join("src/App.jsx"), app_jsx()).unwrap(); + std::fs::write(dir.join("src/styles.css"), ".pricing-grid { display: grid; gap: 20px; }\n.pricing-card { padding: 20px; }\n").unwrap(); + std::fs::write(dir.join("index.html"), "
").unwrap(); + std::fs::write(dir.join("package.json"), "{\"name\":\"t\"}").unwrap(); + dir + } + + fn accept(dir: &PathBuf, args: &[&str]) -> Value { + let env: Env = std::env::vars().collect(); + let (mut io, captured) = Io::captured("", dir.clone(), env); + let argv: Vec = args.iter().map(|a| a.to_string()).collect(); + let code = run(&argv, &mut io); + drop(io); + let out = String::from_utf8_lossy(&captured.stdout.borrow()).into_owned(); + let err = String::from_utf8_lossy(&captured.stderr.borrow()).into_owned(); + assert_eq!(code, 0, "stdout: {out}\nstderr: {err}"); + serde_json::from_str(out.trim()).unwrap_or_else(|e| panic!("{e}: {out}")) + } + + #[test] + fn a_bake_makes_the_variant_permanent_and_appends_its_rules() { + let dir = project("flag"); + let result = accept(&dir, &["--id", SESSION, "--variant", "2", "--bake"]); + assert_eq!(result["handled"], json!(true), "{result}"); + assert_eq!(result["baked"], json!(true), "{result}"); + assert_eq!(result["carbonize"], json!(false)); + assert_eq!(result["variant"], json!("2")); + assert_eq!(result["css"]["file"], json!("src/styles.css"), "{result}"); + assert_eq!(result["css"]["rules"], json!(2)); + assert_eq!(result["css"]["anchor"], json!("div.pricing-grid")); + assert_eq!(result["verify"]["clean"], json!(true), "{result}"); + let jsx = std::fs::read_to_string(dir.join("src/App.jsx")).unwrap(); + assert!(!jsx.contains("data-impeccable"), "{jsx}"); + assert!(!jsx.contains("impeccable-variants"), "{jsx}"); + assert!(!jsx.contains("Simple pricing\n
\n
Starter
\n
\n "), "{jsx}"); + let css = std::fs::read_to_string(dir.join("src/styles.css")).unwrap(); + assert!(css.starts_with(".pricing-grid { display: grid; gap: 20px; }\n"), "existing rules untouched: {css}"); + assert!(css.contains("/* impeccable generate ab12cd34: accepted variant 2 */"), "{css}"); + assert!(css.contains(".pricing-grid { gap: 32px; }"), "{css}"); + assert!(css.contains("div.pricing-grid .pricing-card { border: 2px solid #111; }"), "{css}"); + assert!(!css.contains("8px") && !css.contains("gap: 0"), "other variants dropped: {css}"); + assert!(!css.contains(":scope") && !css.contains("data-impeccable"), "{css}"); + // Idempotent: the receipt answers a rerun. + let again = accept(&dir, &["--id", SESSION, "--variant", "2", "--bake"]); + assert_eq!(again["alreadyApplied"], json!(true), "{again}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_agent_started_session_bakes_by_default_and_plain_live_does_not() { + let dir = project("origin"); + let cwd = dir.to_string_lossy().into_owned(); + let env: Env = std::env::vars().collect(); + // Plain live: no origin, no flag -> the carbonize block, as before. + let plain = accept(&dir, &["--id", SESSION, "--variant", "2"]); + assert_eq!(plain["carbonize"], json!(true), "{plain}"); + assert!(plain.get("baked").is_none(), "{plain}"); + let jsx = std::fs::read_to_string(dir.join("src/App.jsx")).unwrap(); + assert!(jsx.contains("impeccable-carbonize-start"), "{jsx}"); + assert!(!std::fs::read_to_string(dir.join("src/styles.css")).unwrap().contains("32px")); + + // The generate verb's session: the journal says origin agent. + let dir2 = project("origin2"); + let cwd2 = dir2.to_string_lossy().into_owned(); + let store = create_live_session_store(&cwd2, &env, Some(SESSION)); + store + .append_event(&json!({ "type": "generate", "id": SESSION, "origin": "agent", "count": 3, "pageUrl": "/", "action": "bolder" })) + .unwrap(); + let baked = accept(&dir2, &["--id", SESSION, "--variant", "2"]); + assert_eq!(baked["baked"], json!(true), "{baked}"); + assert!(!std::fs::read_to_string(dir2.join("src/App.jsx")).unwrap().contains("data-impeccable")); + // --no-bake wins over the origin. + let dir3 = project("origin3"); + let cwd3 = dir3.to_string_lossy().into_owned(); + create_live_session_store(&cwd3, &env, Some(SESSION)) + .append_event(&json!({ "type": "generate", "id": SESSION, "origin": "agent", "count": 3, "pageUrl": "/", "action": "bolder" })) + .unwrap(); + let kept = accept(&dir3, &["--id", SESSION, "--variant", "2", "--no-bake"]); + assert_eq!(kept["carbonize"], json!(true), "{kept}"); + let _ = (cwd, cwd2, cwd3); + for d in [dir, dir2, dir3] { + let _ = std::fs::remove_dir_all(&d); + } + } + + #[test] + fn a_knob_session_falls_back_to_the_carbonize_block_with_the_reason() { + let dir = project("knobs"); + let src = std::fs::read_to_string(dir.join("src/App.jsx")).unwrap().replace("gap: 32px", "gap: var(--p-gap, 32px)"); + std::fs::write(dir.join("src/App.jsx"), src).unwrap(); + let result = accept(&dir, &["--id", SESSION, "--variant", "2", "--bake"]); + assert_eq!(result["carbonize"], json!(true), "{result}"); + assert!(result["bakeSkipped"].as_str().unwrap().contains("knobs"), "{result}"); + let jsx = std::fs::read_to_string(dir.join("src/App.jsx")).unwrap(); + assert!(jsx.contains("impeccable-carbonize-start"), "{jsx}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/live/src/live_generate.rs b/crates/live/src/live_generate.rs index 529c820ef..b5554894f 100644 --- a/crates/live/src/live_generate.rs +++ b/crates/live/src/live_generate.rs @@ -4,9 +4,11 @@ //! Asks the live overlay to find an element by CSS selector, scroll to it, //! enter the picked state, and fire the normal Go pipeline with the given //! action and count. On success the browser starts a standard generate -//! session; the agent then handles the resulting `generate` event from the -//! poll loop exactly as live.md describes. Requires a running live helper -//! server (`impeccable live` boot) and an open page with the overlay attached. +//! session and the verb collects that session's `generate` event into its +//! own output, so the agent's next move is the edit. With `--boot` it runs +//! the lane's boot itself first (reusing a running helper), and with +//! `--open` it opens the dev URL in the browser when no page is connected: +//! one command from a cold project to a leased generate event. use crate::live_resume::self_cmd; use crate::paths::read_live_server_info; @@ -17,9 +19,25 @@ use impeccable_common::Io; use serde_json::{json, Map, Value}; use std::time::{Duration, Instant}; -const HELP: &str = "Usage: impeccable live-generate --selector [--text ] [--index ] [--action ] [--count ] [--prompt ] [--dry-run] [--wait-for-browser ] [--no-live-bar] +const HELP: &str = "Usage: impeccable live-generate --selector [--text ] [--index ] [--action ] [--count ] [--prompt ] [--dry-run] [--wait-for-browser ] [--no-live-bar] [--boot] [--open] [--target ] Flags: + --boot optional; run the generate lane's boot first (impeccable live + --allow-missing-context --dev-url --no-live-bar, with --target + when given), reusing a running helper; the boot's context and + devUrl ride along in the output as `boot` + --open optional; when no page with the overlay is connected, open the + dev URL in the browser (IMPECCABLE_BROWSER, then `browser` in + .impeccable/config.local.json or config.json, then BROWSER, then + the platform opener) and wait for it (60 s unless + --wait-for-browser says otherwise). On a harness with its own + browser (Cursor, Claude Code) the flag is ignored unless + IMPECCABLE_BROWSER or the config's `browser` names one: the + page comes from the harness browser, never a second window + --target optional; the file that renders the element (the boot's --target) + --dev-url optional; the dev server you already know (a server the + harness runs, a tab on the app, the user's message); probed + first, reported as devUrl either way --selector required; resolved with document.querySelectorAll --text optional; keeps only matches whose textContent contains it --index optional; 1-based pick among the remaining matches @@ -30,6 +48,15 @@ Flags: --wait-for-browser optional; poll the helper until a page with the overlay connects (or the budget runs out) before sending the target. + +With no page connected and neither --open nor --wait-for-browser, the verdict +is browser_needed with devUrl and the harness's way to open it (Cursor +browser_navigate, Claude Code's Browser pane, Codex: --open or the user), so no +second browser is ever launched behind a harness that has one. + +On success the output carries the session's generate event as `event` (already +leased, with its _instructions), so the next command is the edit, then +`live-poll --reply done --file --then-poll` for the accept. "; /// Client-side cap just above the server's 15s hold, so a hung helper still @@ -40,12 +67,16 @@ struct Flags { values: Map, dry_run: bool, no_live_bar: bool, + boot: bool, + open: bool, } fn parse_flags(argv: &[String]) -> Result { let mut values = Map::new(); let mut dry_run = false; let mut no_live_bar = false; + let mut boot = false; + let mut open = false; let mut i = 0; while i < argv.len() { let arg = &argv[i]; @@ -64,6 +95,34 @@ fn parse_flags(argv: &[String]) -> Result { i += 1; continue; } + if key == "boot" { + boot = true; + i += 1; + continue; + } + if key == "open" { + open = true; + i += 1; + continue; + } + // The boot's own opt-in, tolerated here so a caller that spells the + // lane's boot flags on this verb is not refused. + if key == "allow-missing-context" { + i += 1; + continue; + } + // `--dev-url` alone is the boot's probe flag (tolerated); with a + // value it is the dev server the caller already knows. + if key == "dev-url" { + match argv.get(i + 1) { + Some(v) if !v.starts_with("--") => { + values.insert(key.to_string(), json!(v)); + i += 2; + } + _ => i += 1, + } + continue; + } match argv.get(i + 1) { Some(v) if !v.starts_with("--") => { values.insert(key.to_string(), json!(v)); @@ -78,6 +137,8 @@ fn parse_flags(argv: &[String]) -> Result { values, dry_run, no_live_bar, + boot, + open, }) } @@ -130,12 +191,24 @@ fn instructions_for(result: &Map, self_cmd: &str) -> Option --then-poll", self_cmd, s("sessionId")); + if result.get("event").map(|e| e.is_object()).unwrap_or(false) { + return Some(format!( + "Session {} started: the browser scrolled to the target and fired Go (action \"{}\", count {}). Its generate event is in this output as `event`, already leased: follow event._instructions (identity from the event, ONE edit, no knobs). When the edit is written, reply and wait for the user's choice in one call: {}. The accept it returns is baked into source mechanically (_acceptResult.baked) and completes the session; then stop the helper.", + s("sessionId"), s("action"), n("count"), reply + )); + } return Some(format!( - "Session {} started: the browser scrolled to the target and fired Go (action \"{}\", count {}). Poll now with {} live-poll; the next event for this session is its generate event, and its _instructions carry the whole fast path (identity from the event, one edit, reply done). Follow them, then keep polling for the accept.", - s("sessionId"), s("action"), n("count"), self_cmd + "Session {} started: the browser scrolled to the target and fired Go (action \"{}\", count {}). Its generate event had not arrived yet: run {} live-poll to collect it (its _instructions carry the fast path: identity from the event, ONE edit, no knobs), then reply and wait for the accept in one call: {}.", + s("sessionId"), s("action"), n("count"), self_cmd, reply )); } let text = match s("error").as_str() { + "no_dev_server" => format!("No dev server is serving this app: none of the usual ports answered with the page carrying the helper's tag (pass --dev-url when you know where it runs). {}", start_dev_server_hint(&s("harness"))), + "browser_needed" => format!("{}The helper is up and no page is connected yet. {} Then rerun this exact command with --wait-for-browser 60000.", open_ignored_note(result), open_in_harness_hint(&s("harness"), &s("devUrl"), self_cmd)), + "browser_open_failed" => format!("The browser could not be launched ({}). Open {} yourself with your harness browser tool, or give the user the URL, then rerun this command with --wait-for-browser 120000.", s("detail"), s("url")), + "no_browser_connected" if result.get("opened").map(|o| o.is_object()).unwrap_or(false) => format!("The page was opened in the browser but no overlay connected within {} ms. The dev server may still be compiling, or the page does not carry the injected tag (check pageFiles). Reload the page, then rerun this command.", n("waitedMs")), + "no_browser_connected" if !s("devUrl").is_empty() => format!("{}No page with the live overlay connected within {} ms. {} Then rerun this exact command with --wait-for-browser 60000.", open_ignored_note(result), n("waitedMs"), open_in_harness_hint(&s("harness"), &s("devUrl"), self_cmd)), "no_browser_connected" => "No page with the live overlay is connected. Open the app URL that serves a pageFiles entry yourself with your harness browser tool, then rerun this command. Only when no browser tool exists: give the user the URL and rerun with --wait-for-browser 120000 so the command fires as soon as they open the page.".to_string(), "browser_timeout" => "The overlay did not answer in time, and no session was started for this request (a Go that lands late is refused). The page may be mid-reload: reload the app page, then rerun this command.".to_string(), "invalid_selector" => "The selector is not valid CSS. Fix the selector syntax and rerun.".to_string(), @@ -162,6 +235,37 @@ fn instructions_for(result: &Map, self_cmd: &str) -> Option) -> &'static str { + if result.get("openIgnored").is_some() { + "--open was ignored: this harness has its own browser, and a second window is exactly what the lane avoids. " + } else { + "" + } +} + +/// How this harness opens a page: its own browser when it has one (no second +/// browser behind it), the system browser or the user otherwise. +fn open_in_harness_hint(harness: &str, dev_url: &str, self_cmd: &str) -> String { + let _ = self_cmd; + match harness { + "cursor" => format!("Open {} with browser_navigate (Cursor's browser; it reuses the tab already on that origin).", dev_url), + "claude-code" => format!("Open {} in the Browser pane: navigate the tab already on that origin (tabs_context lists them), or preview_start with that URL when the pane is closed.", dev_url), + "codex" => format!("Codex has no browser tool: rerun this command with --open (the system browser opens {}), or give the user that URL.", dev_url), + _ => format!("Open {} with your harness's browser tool, reusing a tab already on that origin; without one, rerun this command with --open (the system browser), or give the user that URL.", dev_url), + } +} + +/// Where a dev server gets started in this harness, so the one already +/// running there is the one the page comes from. +fn start_dev_server_hint(harness: &str) -> String { + match harness { + "claude-code" => "Start it the way the harness runs servers (preview_start with the project's dev configuration, or the dev script in a background shell), wait for its URL, then rerun this exact command with --dev-url ; never kill or restart that server afterwards.".to_string(), + "cursor" => "Start the project's dev script in a background terminal (npm run dev or the framework's equivalent), wait for it to print its URL, then rerun this exact command with --dev-url ; never kill or restart that server afterwards.".to_string(), + _ => "Start the project's dev script in a background terminal (npm run dev or the framework's equivalent), wait for it to print its URL, then rerun this exact command with --dev-url ; never kill or restart that server afterwards.".to_string(), + } +} + fn server_died(self_cmd: &str, detail: Option, waiting: bool) -> Value { let mut v = Map::new(); v.insert("ok".into(), json!(false)); @@ -186,17 +290,157 @@ fn server_not_running(self_cmd: &str) -> Value { }) } +/// The lane's boot flags, run in-process from the caller's original cwd +/// (`--target` is a path relative to it). Ok: the boot payload. Err: a +/// verdict to print, exit 1. +fn run_boot(args: &[String], original_cwd: &std::path::Path, io: &Io, dev_url_hint: Option<&str>) -> Result, Value> { + let mut boot_args: Vec = Vec::new(); + if let Some(i) = args.iter().position(|a| a == "--target") { + if let Some(t) = args.get(i + 1).filter(|t| !t.starts_with("--")) { + boot_args.push("--target".into()); + boot_args.push(t.clone()); + } + } + for a in args { + if let Some(t) = a.strip_prefix("--target=") { + boot_args.push("--target".into()); + boot_args.push(t.to_string()); + } + } + boot_args.push("--allow-missing-context".into()); + boot_args.push("--dev-url".into()); + boot_args.push("--no-live-bar".into()); + let mut env = io.env.clone(); + if let Some(hint) = dev_url_hint { + // The known server first, the usual ports behind it, unless the + // caller already narrowed the list. + if !env.contains_key("IMPECCABLE_DEV_URL_CANDIDATES") { + let mut list = vec![hint.trim_end_matches('/').to_string() + "/"]; + list.extend(crate::dev_url::candidates(None)); + env.insert("IMPECCABLE_DEV_URL_CANDIDATES".into(), list.join(",")); + } + } + let (mut child, captured) = Io::captured("", original_cwd.to_path_buf(), env); + let code = crate::live_boot::run(&boot_args, &mut child); + let out = String::from_utf8_lossy(&captured.stdout.borrow()).into_owned(); + let err = String::from_utf8_lossy(&captured.stderr.borrow()).into_owned(); + let payload: Option> = serde_json::from_str::(out.trim()) + .ok() + .and_then(|v| v.as_object().cloned()); + let Some(mut payload) = payload else { + return Err(json!({ + "ok": false, + "error": "boot_failed", + "exitCode": code, + "detail": if err.trim().is_empty() { out.trim().to_string() } else { err.trim().to_string() }, + "_instructions": "The live boot did not produce a verdict. Run `impeccable live --allow-missing-context --dev-url --no-live-bar` on its own, read its output, and fix what it names before rerunning this command.", + })); + }; + if payload.get("ok").and_then(Value::as_bool) != Some(true) { + let error = payload.get("error").and_then(Value::as_str).unwrap_or("").to_string(); + let text = match error.as_str() { + "config_missing" | "config_invalid" => "The live config is missing or invalid: follow reference/live-setup.md to create .impeccable/live/config.json, then rerun this command.", + "target_selection_required" => "Several apps live here: ask the user which one, then rerun this command with --target
.", + "context_missing" => "The boot refused for missing context even though this verb asks it to proceed; rerun with the boot's own flags to see why.", + _ => "The boot refused; its fields say why. Fix that, then rerun this command.", + }; + payload.insert("ok".into(), json!(false)); + payload.insert("bootError".into(), json!(error)); + payload.insert("_instructions".into(), json!(text)); + return Err(Value::Object(payload)); + } + Ok(payload) +} + +/// What the verdict repeats from the boot: the context the edit needs and +/// where the page is. Plumbing (token, roots, drift) stays out. +fn boot_summary(boot: &Map) -> Value { + let mut m = Map::new(); + for key in [ + "devUrl", "pageFiles", "projectRoot", "targetPath", "liveBarHidden", "contextMissing", "contextNote", + "hasProduct", "product", "productPath", "hasDesign", "design", "designPath", "hasSurfaceBrief", + "surfaceBrief", "surfaceBriefPath", + ] { + if let Some(v) = boot.get(key) { + m.insert(key.into(), v.clone()); + } + } + Value::Object(m) +} + +/// Collect the session's own generate event (`GET /poll?types=generate&id=`) +/// so the caller's next move is the edit. The event is leased exactly as a +/// poll would lease it; nothing else in the queue is touched. +fn fetch_generate_event(port: i64, token: &str, session_id: &str, budget: Duration, self_cmd: &str) -> Option { + let deadline = Instant::now() + budget; + while Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(Instant::now()).as_millis() as u64; + let slice = remaining.clamp(1_000, 5_000); + let url = format!( + "http://127.0.0.1:{}/poll?token={}&timeout={}&leaseMs={}&types=generate&id={}", + port, + crate::live_poll::form_encode(token), + slice, + crate::live_poll::DEFAULT_EVENT_LEASE_MS, + crate::live_poll::form_encode(session_id) + ); + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_millis(slice + 30_000)) + .build(); + let Ok(res) = agent.get(&url).call() else { return None }; + let Ok(mut event) = res.into_json::() else { return None }; + match event.get("type").and_then(Value::as_str) { + Some("generate") => { + if let Some(obj) = event.as_object_mut() { + match crate::instructions::instructions_for_event(obj, self_cmd) { + Some(text) if !text.is_empty() => { + obj.insert("_instructions".into(), json!(text)); + } + _ => { + obj.remove("_instructions"); + } + } + } + return Some(event); + } + Some("timeout") => continue, + _ => return None, + } + } + None +} + +/// How long the verb waits for the generate event after a started session. +const EVENT_BUDGET_MS: u64 = 20_000; +/// The wait `--open` implies when `--wait-for-browser` was not given. +const OPEN_WAIT_MS: u64 = 60_000; + pub fn run(args: &[String], io: &mut Io) -> i32 { + if args.iter().any(|a| a == "--help" || a == "-h") { + println(io, HELP); + return 0; + } + let flags_probe = match parse_flags(args) { + Ok(f) => f, + Err(v) => return fail(io, v), + }; + // `--boot` runs before the root switch: the boot writes the roots + // manifest the switch reads, and reads --target relative to this cwd. + let original_cwd = io.cwd.clone(); + let dev_url_hint: Option = flag(&flags_probe, "dev-url").map(str::trim).filter(|u| !u.is_empty()).map(str::to_string); + let mut boot: Option> = None; + if flags_probe.boot { + match run_boot(args, &original_cwd, io, dev_url_hint.as_deref()) { + Ok(b) => boot = Some(b), + Err(v) => return fail(io, v), + } + } let mut argv: Vec = args.to_vec(); if let Err(code) = enter_live_root(&mut argv, io) { return code; } let cwd = io.cwd.to_string_lossy().into_owned(); let env = io.env.clone(); - if argv.iter().any(|a| a == "--help" || a == "-h") { - println(io, HELP); - return 0; - } let me = self_cmd(io); let flags = match parse_flags(&argv) { Ok(f) => f, @@ -239,7 +483,7 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { } }, }; - let wait_for_browser_ms = match flag(&flags, "wait-for-browser") { + let mut wait_for_browser_ms = match flag(&flags, "wait-for-browser") { None => 0, Some(raw) => match int_flag(raw) { Some(ms) if ms >= 1 => ms as u64, @@ -257,6 +501,113 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { let (Some(port), Some(token)) = (port, token) else { return fail(io, server_not_running(&me)); }; + let harness = impeccable_context::provider::detect(&env, &cwd).id; + // Every verdict from here on repeats what the boot found, so a refusal + // still hands the caller its context and dev URL. + let with_boot = |mut v: Map| -> Value { + if let Some(b) = &boot { + v.insert("boot".into(), boot_summary(b)); + } + v.insert("harness".into(), json!(harness)); + Value::Object(v) + }; + + // Where the page is: the boot's probe, else a probe led by the caller's + // hint, else the hint itself (a server the harness runs that answers + // without our tag yet, before its first reload). + let resolve_dev_url = |boot: &Option>| -> (Option, bool) { + if let Some(u) = boot.as_ref().and_then(|b| b.get("devUrl")).and_then(Value::as_str).filter(|u| !u.is_empty()) { + return (Some(u.to_string()), true); + } + let mut candidates: Vec = Vec::new(); + if let Some(h) = &dev_url_hint { + candidates.push(h.trim_end_matches('/').to_string() + "/"); + } + candidates.extend(crate::dev_url::candidates(env.get("IMPECCABLE_DEV_URL_CANDIDATES").map(String::as_str))); + if let Some(u) = crate::dev_url::probe(&candidates, &token) { + return (Some(u), true); + } + (dev_url_hint.clone(), false) + }; + + // A harness with its own browser never gets a second window from this + // verb: `--open` there is ignored unless the user chose a browser + // explicitly (IMPECCABLE_BROWSER or the config's `browser`; the generic + // BROWSER variable is not that choice). + let harness_has_browser = matches!(harness.as_str(), "cursor" | "claude-code"); + let open_ignored = flags.open && harness_has_browser && crate::browser_open::explicit_browser(&cwd, &env).is_none(); + let open = flags.open && !open_ignored; + let with_open_note = |mut v: Map| -> Map { + if open_ignored { + v.insert("openIgnored".into(), json!("harness browser")); + } + v + }; + + // Nothing connected, nothing asked to open, nothing to wait for: the + // caller opens the page itself (its harness's browser, never a second + // one behind it) and comes back. + let mut opened: Option = None; + if !open && wait_for_browser_ms == 0 && !flags.dry_run { + let Some(status) = crate::server::fetch_status(port, &token) else { + return fail(io, server_died(&me, None, false)); + }; + if status.get("connectedClients").and_then(Value::as_i64).unwrap_or(0) == 0 { + let (dev_url, verified) = resolve_dev_url(&boot); + let mut v = Map::new(); + v.insert("ok".into(), json!(false)); + if let Some(u) = dev_url { + v.insert("error".into(), json!("browser_needed")); + v.insert("devUrl".into(), json!(u)); + v.insert("devUrlVerified".into(), json!(verified)); + } else { + v.insert("error".into(), json!("no_dev_server")); + } + v.insert("harness".into(), json!(harness)); + let mut v = with_open_note(v); + let text = instructions_for(&v, &me).unwrap_or_default(); + v.insert("_instructions".into(), json!(text)); + return fail(io, with_boot(v)); + } + } + + // `--open`: hand the page to the browser when nothing is connected yet. + if open { + let Some(status) = crate::server::fetch_status(port, &token) else { + return fail(io, server_died(&me, None, false)); + }; + let connected = status.get("connectedClients").and_then(Value::as_i64).unwrap_or(0) > 0; + if !connected { + let (dev_url, _) = resolve_dev_url(&boot); + let Some(url) = dev_url else { + let mut v = Map::new(); + v.insert("ok".into(), json!(false)); + v.insert("error".into(), json!("no_dev_server")); + v.insert("harness".into(), json!(harness)); + let text = instructions_for(&v, &me).unwrap_or_default(); + v.insert("_instructions".into(), json!(text)); + return fail(io, with_boot(v)); + }; + match crate::browser_open::open_url(&url, &cwd, &env) { + Ok(via) => { + opened = Some(json!({ "url": url, "via": via })); + if wait_for_browser_ms == 0 { + wait_for_browser_ms = OPEN_WAIT_MS; + } + } + Err(detail) => { + let mut v = Map::new(); + v.insert("ok".into(), json!(false)); + v.insert("error".into(), json!("browser_open_failed")); + v.insert("url".into(), json!(url)); + v.insert("detail".into(), json!(detail)); + let text = instructions_for(&v, &me).unwrap_or_default(); + v.insert("_instructions".into(), json!(text)); + return fail(io, with_boot(v)); + } + } + } + } if wait_for_browser_ms > 0 { let deadline = Instant::now() + Duration::from_millis(wait_for_browser_ms); @@ -272,9 +623,16 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { v.insert("ok".into(), json!(false)); v.insert("error".into(), json!("no_browser_connected")); v.insert("waitedMs".into(), json!(wait_for_browser_ms)); + if let Some(o) = &opened { + v.insert("opened".into(), o.clone()); + } else if let (Some(u), _) = resolve_dev_url(&boot) { + v.insert("devUrl".into(), json!(u)); + } + v.insert("harness".into(), json!(harness)); + let mut v = with_open_note(v); let text = instructions_for(&v, &me).unwrap_or_default(); v.insert("_instructions".into(), json!(text)); - return fail(io, Value::Object(v)); + return fail(io, with_boot(v)); } std::thread::sleep(Duration::from_millis(1_000)); } @@ -297,7 +655,7 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { if flags.dry_run { body.insert("dryRun".into(), json!(true)); } - if flags.no_live_bar { + if flags.no_live_bar || flags.boot { body.insert("hideLiveBar".into(), json!(true)); } @@ -353,9 +711,23 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { v.insert(k, val); } } - return fail(io, Value::Object(v)); + return fail(io, with_boot(v)); } let ok = fields.get("ok").and_then(Value::as_bool) == Some(true); + let dry_run = fields.get("dryRun").and_then(Value::as_bool) == Some(true); + if let Some(b) = &boot { + fields.insert("boot".into(), boot_summary(b)); + } + if let Some(o) = &opened { + fields.insert("opened".into(), o.clone()); + } + if ok && !dry_run { + let session_id = fields.get("sessionId").and_then(Value::as_str).map(str::to_string); + if let Some(sid) = session_id.filter(|s| !s.is_empty()) { + let event = fetch_generate_event(port, &token, &sid, Duration::from_millis(EVENT_BUDGET_MS), &me); + fields.insert("event".into(), event.unwrap_or(Value::Null)); + } + } if let Some(text) = instructions_for(&fields, &me) { fields.insert("_instructions".into(), json!(text)); } @@ -389,6 +761,64 @@ mod tests { assert!(!parse_flags(&["--selector".to_string(), "h1".to_string()]).unwrap().no_live_bar); } + #[test] + fn dev_url_takes_a_value_and_still_works_bare() { + let with = parse_flags(&["--dev-url".to_string(), "http://127.0.0.1:5173/".to_string(), "--selector".to_string(), "h1".to_string()]).unwrap(); + assert_eq!(with.values.get("dev-url").and_then(Value::as_str), Some("http://127.0.0.1:5173/")); + let bare = parse_flags(&["--dev-url".to_string(), "--selector".to_string(), "h1".to_string()]).unwrap(); + assert!(bare.values.get("dev-url").is_none()); + assert_eq!(bare.values.get("selector").and_then(Value::as_str), Some("h1")); + } + + #[test] + fn browser_needed_names_the_harness_browser_and_never_a_second_one() { + let mut m = Map::new(); + m.insert("ok".into(), json!(false)); + m.insert("error".into(), json!("browser_needed")); + m.insert("devUrl".into(), json!("http://127.0.0.1:5173/")); + m.insert("harness".into(), json!("cursor")); + let cursor = instructions_for(&m, "impeccable").unwrap(); + assert!(cursor.contains("browser_navigate"), "{cursor}"); + assert!(cursor.contains("--wait-for-browser 60000"), "{cursor}"); + assert!(!cursor.contains("--open"), "a harness with a browser is never told to open a second one: {cursor}"); + m.insert("harness".into(), json!("claude-code")); + let claude = instructions_for(&m, "impeccable").unwrap(); + assert!(claude.contains("Browser pane") && claude.contains("navigate") && claude.contains("preview_start"), "{claude}"); + assert!(!claude.contains("--open"), "{claude}"); + m.insert("harness".into(), json!("codex")); + let codex = instructions_for(&m, "impeccable").unwrap(); + assert!(codex.contains("--open") && codex.contains("give the user"), "{codex}"); + m.insert("harness".into(), json!("source")); + let other = instructions_for(&m, "impeccable").unwrap(); + assert!(other.contains("browser tool") && other.contains("--open"), "{other}"); + } + + #[test] + fn an_ignored_open_says_so_before_the_harness_hint() { + let mut m = Map::new(); + m.insert("ok".into(), json!(false)); + m.insert("error".into(), json!("browser_needed")); + m.insert("devUrl".into(), json!("http://127.0.0.1:5173/")); + m.insert("harness".into(), json!("cursor")); + m.insert("openIgnored".into(), json!("harness browser")); + let text = instructions_for(&m, "impeccable").unwrap(); + assert!(text.starts_with("--open was ignored"), "{text}"); + assert!(text.contains("browser_navigate"), "{text}"); + } + + #[test] + fn a_missing_dev_server_points_at_the_harness_way_to_start_one() { + let mut m = Map::new(); + m.insert("ok".into(), json!(false)); + m.insert("error".into(), json!("no_dev_server")); + m.insert("harness".into(), json!("claude-code")); + let text = instructions_for(&m, "impeccable").unwrap(); + assert!(text.contains("preview_start") && text.contains("--dev-url"), "{text}"); + m.insert("harness".into(), json!("cursor")); + let text = instructions_for(&m, "impeccable").unwrap(); + assert!(text.contains("background terminal") && text.contains("--dev-url"), "{text}"); + } + #[test] fn timeout_instructions_promise_no_stray_session() { let mut m = Map::new(); diff --git a/crates/live/src/live_poll.rs b/crates/live/src/live_poll.rs index ebd7ef4ea..1a17ffafa 100644 --- a/crates/live/src/live_poll.rs +++ b/crates/live/src/live_poll.rs @@ -31,6 +31,9 @@ Modes: poll --reply error \"msg\" Reply with an error message poll --reply done --data '' Reply with a structured JSON result (manual_edit_apply) + poll --reply done --then-poll + Reply, then keep waiting for the next event in the + same call (the generate lane: done, then the accept) Options: --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode @@ -38,6 +41,8 @@ Options: --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000) --file PATH Attach a source file path to the reply (generate/steer flow) --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON + --then-poll After a successful --reply, run the one-shot poll and print its event + (the reply's ack rides along as _replyAck). --timeout= bounds the wait --help Show this help message Harness note: @@ -324,7 +329,7 @@ fn normalize_poll_types(value: Option<&str>) -> Vec { out } -fn form_encode(s: &str) -> String { +pub(crate) fn form_encode(s: &str) -> String { // URLSearchParams serialization (application/x-www-form-urlencoded) let mut out = String::new(); for b in s.bytes() { @@ -729,12 +734,16 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { return 1; } }; + let then_poll = argv.iter().any(|a| a == "--then-poll"); return match post_reply(&base, &token, &reply) { Ok(()) => { - println( - io, - &serde_json::to_string(&reply_ack_json(&reply)).unwrap_or_default(), - ); + let ack = reply_ack_json(&reply); + if then_poll { + // One round trip instead of two: the reply is in, so wait + // for what the browser does next (usually the accept). + return one_shot_poll(&argv, &base, &token, Some(ack), io); + } + println(io, &serde_json::to_string(&ack).unwrap_or_default()); 0 } Err(PollError::ConnRefused) => { @@ -803,7 +812,20 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { } } - let total_timeout = arg_value_int(&argv, "--timeout=", 600_000); + one_shot_poll(&argv, &base, &token, None, io) +} + +/// The default mode: block until one event (or the `--timeout=` deadline), +/// handle it, print it. `reply_ack` is the `--then-poll` case: the reply +/// that just went out rides along as `_replyAck` on the printed event so +/// the caller sees both halves of its one call. +fn one_shot_poll(argv: &[String], base: &str, token: &str, reply_ack: Option, io: &mut Io) -> i32 { + let types_arg = argv + .iter() + .find(|a| a.starts_with("--types=")) + .map(|a| a["--types=".len()..].to_string()); + let types = normalize_poll_types(types_arg.as_deref()); + let total_timeout = arg_value_int(argv, "--timeout=", 600_000); // JS: Date.now() + NaN -> NaN deadline; comparisons are false, so the // loop never times out. Approximate with a far deadline. let deadline = if total_timeout == i64::MIN { @@ -811,12 +833,24 @@ pub fn run(args: &[String], io: &mut Io) -> i32 { } else { Instant::now() + Duration::from_millis(total_timeout.max(0) as u64) }; - match fetch_next_event(&base, &token, Some(deadline), &types) { - Ok(event) => { - handle_event(event, &base, &token, io); + match fetch_next_event(base, token, Some(deadline), &types) { + Ok(mut event) => { + if let (Some(mut ack), Some(obj)) = (reply_ack, event.as_object_mut()) { + if let Some(a) = ack.as_object_mut() { + a.remove("_instructions"); + } + obj.insert("_replyAck".into(), ack); + } + handle_event(event, base, token, io); 0 } - Err(e) => handle_poll_error(e, io), + Err(e) => { + if let Some(ack) = reply_ack { + // The reply itself succeeded; say so before the poll's error. + println(io, &serde_json::to_string(&ack).unwrap_or_default()); + } + handle_poll_error(e, io) + } } } diff --git a/crates/live/src/live_server.rs b/crates/live/src/live_server.rs index 48b832c10..3aa3afc67 100644 --- a/crates/live/src/live_server.rs +++ b/crates/live/src/live_server.rs @@ -663,9 +663,9 @@ fn handle_connection(shared: Shared, mut stream: TcpStream, mut ticket: Ticket) ); return; } - let (cwd, env, port, roots) = { + let (cwd, env, port, roots, live_bar_hidden) = { let st = lock(&shared); - (st.cwd.clone(), st.env.clone(), st.port, st.roots.clone()) + (st.cwd.clone(), st.env.clone(), st.port, st.roots.clone(), st.hide_live_bar) }; let parts = match read_live_browser_script_parts(scripts_dir(&env, &cwd).as_deref()) { Ok(p) => p, @@ -692,7 +692,7 @@ fn handle_connection(shared: Shared, mut stream: TcpStream, mut ticket: Ticket) roots.as_ref().and_then(|r| r.context_root.as_deref()), roots.as_ref().map(|r| r.repo_root.as_str()), ); - let body = assemble_live_browser_script(&token_now, port, &prefix, &cwd, &parts, &project_ignores); + let body = assemble_live_browser_script(&token_now, port, &prefix, &cwd, &parts, &project_ignores, live_bar_hidden); respond( &mut stream, &cors, @@ -1369,9 +1369,16 @@ fn handle_poll_get( let lease_raw = parse_int_or(req.query_get("leaseMs"), 30000); let lease_ms = if lease_raw == i64::MIN { 0 } else { lease_raw }; let types = parse_poll_types(req.query_get("types")); + // `id=`: only that session's events (the generate verb collecting its + // own generate event leaves every other session's queue alone). + let event_id = req + .query_get("id") + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); let mut st = lock(shared); st.last_poll_at = now_i64(); - if let Some(idx) = st.find_available_pending_event(types.as_deref()) { + if let Some(idx) = st.find_available_pending_event(types.as_deref(), event_id.as_deref()) { st.pending_events[idx].lease_until = now_i64() + lease_ms; let seq = st.pending_events[idx].seq; let event = st.pending_events[idx].event.clone(); @@ -1383,7 +1390,7 @@ fn handle_poll_get( respond(&mut stream, cors, json_res(200, Value::Object(event))); return; } - let (poll_id, rx) = st.park_poll(lease_ms, types); + let (poll_id, rx) = st.park_poll(lease_ms, types, event_id); drop(st); ticket.release(); let done = Arc::new(AtomicBool::new(false)); diff --git a/crates/live/src/server_state.rs b/crates/live/src/server_state.rs index 122f3f3b7..a04b627d4 100644 --- a/crates/live/src/server_state.rs +++ b/crates/live/src/server_state.rs @@ -34,6 +34,9 @@ pub struct ParkedPoll { pub tx: Sender, pub lease_ms: i64, pub types: Option>, + /// `GET /poll?id=`: lease only the events of that session (the + /// generate verb picks up its own event without touching another's). + pub event_id: Option, } pub struct SseClient { @@ -184,12 +187,18 @@ pub fn select_available_pending_event( entries: &[PendingEntry], now: i64, types: Option<&[String]>, + event_id: Option<&str>, ) -> Option { let mut best: Option = None; for (i, entry) in entries.iter().enumerate() { if is_leased_at(entry, now) { continue; } + if let Some(wanted) = event_id { + if entry.event.get("id").and_then(|v| v.as_str()) != Some(wanted) { + continue; + } + } if let Some(allowed) = types { let ty = entry .event @@ -276,8 +285,12 @@ impl ServerState { } } - pub fn find_available_pending_event(&self, types: Option<&[String]>) -> Option { - select_available_pending_event(&self.pending_events, now_i64(), types) + pub fn find_available_pending_event( + &self, + types: Option<&[String]>, + event_id: Option<&str>, + ) -> Option { + select_available_pending_event(&self.pending_events, now_i64(), types, event_id) } /// JS: recordAgentPhase(id, phase, details) @@ -425,9 +438,12 @@ impl ServerState { let mut found: Option<(usize, usize)> = None; let now = now_i64(); for (pi, poll) in self.pending_polls.iter().enumerate() { - if let Some(ei) = - select_available_pending_event(&self.pending_events, now, poll.types.as_deref()) - { + if let Some(ei) = select_available_pending_event( + &self.pending_events, + now, + poll.types.as_deref(), + poll.event_id.as_deref(), + ) { found = Some((pi, ei)); break; } @@ -641,6 +657,7 @@ impl ServerState { &mut self, lease_ms: i64, types: Option>, + event_id: Option, ) -> (u64, Receiver) { let (tx, rx) = channel(); let id = self.next_poll_id; @@ -650,6 +667,7 @@ impl ServerState { tx, lease_ms, types, + event_id, }); self.broadcast_agent_polling_if_changed(); self.schedule_lease_flush(); diff --git a/crates/live/src/session.rs b/crates/live/src/session.rs index d6405a635..e7d1b9d0d 100644 --- a/crates/live/src/session.rs +++ b/crates/live/src/session.rs @@ -508,6 +508,12 @@ pub fn apply_event(snapshot: &Map, entry: &Value) -> Map { set!("phase", json!("generate_requested")); + // `origin: "agent"` marks a Go the generate verb fired; the + // accept pipeline bakes those sessions itself. A plain Go + // carries no origin and its snapshot stays exactly as it was. + if let Some(origin) = ev("origin").filter(|v| truthy(v)) { + set!("origin", origin.clone()); + } set_if!("pageUrl", ev("pageUrl")); set_if!("expectedVariants", ev("count")); set_if!("pendingEventSeq", seq.as_ref()); diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 8beed026c..16d2aeb0b 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -1414,7 +1414,7 @@ Schema (`validateConfig`, error messages verbatim): - Server picks port: `--port=N` or first free port from 8400 upward (bind 127.0.0.1). Token = `randomUUID()`. - Written on listen: `.impeccable/live/server.json` = `{"pid","port","token"}`. - `readLiveServerInfo(cwd)`: tries primary then legacy `.impeccable-live.json`; if `pid` recorded and `process.kill(pid,0)` throws ESRCH → unlink that file and continue; EPERM counts as alive. Returns `{info, path}` or null. -- Browser gets the token from the injected `