@selvakumaresra/specship 0.1.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/commands/ss-design-implement.md +84 -0
- package/commands/ss-design-loop.md +125 -0
- package/commands/{cg-drifted.md → ss-drifted.md} +2 -2
- package/commands/{cg-fix.md → ss-fix.md} +1 -1
- package/commands/{cg-implement.md → ss-implement.md} +1 -1
- package/commands/ss-spec-author.md +43 -0
- package/commands/ss-spec-review.md +48 -0
- package/dist/bin/node-version-check.d.ts +37 -0
- package/dist/bin/node-version-check.d.ts.map +1 -0
- package/dist/bin/node-version-check.js +79 -0
- package/dist/bin/node-version-check.js.map +1 -0
- package/dist/bin/specship.d.ts +25 -0
- package/dist/bin/specship.d.ts.map +1 -0
- package/dist/bin/specship.js +2085 -0
- package/dist/bin/specship.js.map +1 -0
- package/dist/bin/uninstall.d.ts +13 -0
- package/dist/bin/uninstall.d.ts.map +1 -0
- package/dist/bin/uninstall.js +35 -0
- package/dist/bin/uninstall.js.map +1 -0
- package/dist/context/formatter.d.ts +30 -0
- package/dist/context/formatter.d.ts.map +1 -0
- package/dist/context/formatter.js +263 -0
- package/dist/context/formatter.js.map +1 -0
- package/dist/context/index.d.ts +119 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +1289 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context/markers.d.ts +19 -0
- package/dist/context/markers.d.ts.map +1 -0
- package/dist/context/markers.js +22 -0
- package/dist/context/markers.js.map +1 -0
- package/dist/db/index.d.ts +103 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +279 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migrations.d.ts +44 -0
- package/dist/db/migrations.d.ts.map +1 -0
- package/dist/db/migrations.js +462 -0
- package/dist/db/migrations.js.map +1 -0
- package/dist/db/queries.d.ts +357 -0
- package/dist/db/queries.d.ts.map +1 -0
- package/dist/db/queries.js +1504 -0
- package/dist/db/queries.js.map +1 -0
- package/dist/db/schema.sql +419 -0
- package/dist/db/spec-queries.d.ts +101 -0
- package/dist/db/spec-queries.d.ts.map +1 -0
- package/dist/db/spec-queries.js +675 -0
- package/dist/db/spec-queries.js.map +1 -0
- package/dist/db/sqlite-adapter.d.ts +65 -0
- package/dist/db/sqlite-adapter.d.ts.map +1 -0
- package/dist/db/sqlite-adapter.js +214 -0
- package/dist/db/sqlite-adapter.js.map +1 -0
- package/dist/designer/artifact-store.js +54 -0
- package/dist/designer/browser.js +141 -0
- package/dist/designer/cdp-ensure.js +60 -0
- package/dist/designer/cdp-env.js +18 -0
- package/dist/designer/cdp-trace.js +599 -0
- package/dist/designer/cross-platform.js +74 -0
- package/dist/designer/designer-controller.js +1413 -0
- package/dist/designer/file-panel.js +39 -0
- package/dist/designer/interstitials.js +97 -0
- package/dist/designer/oopif-reader.js +176 -0
- package/dist/designer/package-meta.js +18 -0
- package/dist/designer/preview-host.js +50 -0
- package/dist/designer/repo-root.js +31 -0
- package/dist/designer/run-state.js +353 -0
- package/dist/designer/session-store.js +59 -0
- package/dist/designer/ui-anchors.js +651 -0
- package/dist/directory.d.ts +67 -0
- package/dist/directory.d.ts.map +1 -0
- package/dist/directory.js +267 -0
- package/dist/directory.js.map +1 -0
- package/dist/errors.d.ts +136 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +219 -0
- package/dist/errors.js.map +1 -0
- package/dist/extraction/dfm-extractor.d.ts +31 -0
- package/dist/extraction/dfm-extractor.d.ts.map +1 -0
- package/dist/extraction/dfm-extractor.js +151 -0
- package/dist/extraction/dfm-extractor.js.map +1 -0
- package/dist/extraction/generated-detection.d.ts +30 -0
- package/dist/extraction/generated-detection.d.ts.map +1 -0
- package/dist/extraction/generated-detection.js +80 -0
- package/dist/extraction/generated-detection.js.map +1 -0
- package/dist/extraction/grammars.d.ts +100 -0
- package/dist/extraction/grammars.d.ts.map +1 -0
- package/dist/extraction/grammars.js +426 -0
- package/dist/extraction/grammars.js.map +1 -0
- package/dist/extraction/index.d.ts +138 -0
- package/dist/extraction/index.d.ts.map +1 -0
- package/dist/extraction/index.js +1394 -0
- package/dist/extraction/index.js.map +1 -0
- package/dist/extraction/languages/c-cpp.d.ts +4 -0
- package/dist/extraction/languages/c-cpp.d.ts.map +1 -0
- package/dist/extraction/languages/c-cpp.js +171 -0
- package/dist/extraction/languages/c-cpp.js.map +1 -0
- package/dist/extraction/languages/csharp.d.ts +3 -0
- package/dist/extraction/languages/csharp.d.ts.map +1 -0
- package/dist/extraction/languages/csharp.js +73 -0
- package/dist/extraction/languages/csharp.js.map +1 -0
- package/dist/extraction/languages/dart.d.ts +3 -0
- package/dist/extraction/languages/dart.d.ts.map +1 -0
- package/dist/extraction/languages/dart.js +192 -0
- package/dist/extraction/languages/dart.js.map +1 -0
- package/dist/extraction/languages/go.d.ts +3 -0
- package/dist/extraction/languages/go.d.ts.map +1 -0
- package/dist/extraction/languages/go.js +74 -0
- package/dist/extraction/languages/go.js.map +1 -0
- package/dist/extraction/languages/index.d.ts +10 -0
- package/dist/extraction/languages/index.d.ts.map +1 -0
- package/dist/extraction/languages/index.js +51 -0
- package/dist/extraction/languages/index.js.map +1 -0
- package/dist/extraction/languages/java.d.ts +3 -0
- package/dist/extraction/languages/java.d.ts.map +1 -0
- package/dist/extraction/languages/java.js +70 -0
- package/dist/extraction/languages/java.js.map +1 -0
- package/dist/extraction/languages/javascript.d.ts +3 -0
- package/dist/extraction/languages/javascript.d.ts.map +1 -0
- package/dist/extraction/languages/javascript.js +90 -0
- package/dist/extraction/languages/javascript.js.map +1 -0
- package/dist/extraction/languages/kotlin.d.ts +3 -0
- package/dist/extraction/languages/kotlin.d.ts.map +1 -0
- package/dist/extraction/languages/kotlin.js +259 -0
- package/dist/extraction/languages/kotlin.js.map +1 -0
- package/dist/extraction/languages/lua.d.ts +3 -0
- package/dist/extraction/languages/lua.d.ts.map +1 -0
- package/dist/extraction/languages/lua.js +150 -0
- package/dist/extraction/languages/lua.js.map +1 -0
- package/dist/extraction/languages/luau.d.ts +3 -0
- package/dist/extraction/languages/luau.d.ts.map +1 -0
- package/dist/extraction/languages/luau.js +37 -0
- package/dist/extraction/languages/luau.js.map +1 -0
- package/dist/extraction/languages/objc.d.ts +3 -0
- package/dist/extraction/languages/objc.d.ts.map +1 -0
- package/dist/extraction/languages/objc.js +133 -0
- package/dist/extraction/languages/objc.js.map +1 -0
- package/dist/extraction/languages/pascal.d.ts +3 -0
- package/dist/extraction/languages/pascal.d.ts.map +1 -0
- package/dist/extraction/languages/pascal.js +66 -0
- package/dist/extraction/languages/pascal.js.map +1 -0
- package/dist/extraction/languages/php.d.ts +3 -0
- package/dist/extraction/languages/php.d.ts.map +1 -0
- package/dist/extraction/languages/php.js +107 -0
- package/dist/extraction/languages/php.js.map +1 -0
- package/dist/extraction/languages/python.d.ts +3 -0
- package/dist/extraction/languages/python.d.ts.map +1 -0
- package/dist/extraction/languages/python.js +56 -0
- package/dist/extraction/languages/python.js.map +1 -0
- package/dist/extraction/languages/ruby.d.ts +3 -0
- package/dist/extraction/languages/ruby.d.ts.map +1 -0
- package/dist/extraction/languages/ruby.js +114 -0
- package/dist/extraction/languages/ruby.js.map +1 -0
- package/dist/extraction/languages/rust.d.ts +3 -0
- package/dist/extraction/languages/rust.d.ts.map +1 -0
- package/dist/extraction/languages/rust.js +109 -0
- package/dist/extraction/languages/rust.js.map +1 -0
- package/dist/extraction/languages/scala.d.ts +3 -0
- package/dist/extraction/languages/scala.d.ts.map +1 -0
- package/dist/extraction/languages/scala.js +139 -0
- package/dist/extraction/languages/scala.js.map +1 -0
- package/dist/extraction/languages/swift.d.ts +3 -0
- package/dist/extraction/languages/swift.d.ts.map +1 -0
- package/dist/extraction/languages/swift.js +91 -0
- package/dist/extraction/languages/swift.js.map +1 -0
- package/dist/extraction/languages/typescript.d.ts +3 -0
- package/dist/extraction/languages/typescript.d.ts.map +1 -0
- package/dist/extraction/languages/typescript.js +129 -0
- package/dist/extraction/languages/typescript.js.map +1 -0
- package/dist/extraction/liquid-extractor.d.ts +52 -0
- package/dist/extraction/liquid-extractor.d.ts.map +1 -0
- package/dist/extraction/liquid-extractor.js +313 -0
- package/dist/extraction/liquid-extractor.js.map +1 -0
- package/dist/extraction/mybatis-extractor.d.ts +48 -0
- package/dist/extraction/mybatis-extractor.d.ts.map +1 -0
- package/dist/extraction/mybatis-extractor.js +198 -0
- package/dist/extraction/mybatis-extractor.js.map +1 -0
- package/dist/extraction/parse-worker.d.ts +8 -0
- package/dist/extraction/parse-worker.d.ts.map +1 -0
- package/dist/extraction/parse-worker.js +94 -0
- package/dist/extraction/parse-worker.js.map +1 -0
- package/dist/extraction/specs/markdown-spec-extractor.d.ts +59 -0
- package/dist/extraction/specs/markdown-spec-extractor.d.ts.map +1 -0
- package/dist/extraction/specs/markdown-spec-extractor.js +327 -0
- package/dist/extraction/specs/markdown-spec-extractor.js.map +1 -0
- package/dist/extraction/specs/types.d.ts +39 -0
- package/dist/extraction/specs/types.d.ts.map +1 -0
- package/dist/extraction/specs/types.js +8 -0
- package/dist/extraction/specs/types.js.map +1 -0
- package/dist/extraction/svelte-extractor.d.ts +56 -0
- package/dist/extraction/svelte-extractor.d.ts.map +1 -0
- package/dist/extraction/svelte-extractor.js +272 -0
- package/dist/extraction/svelte-extractor.js.map +1 -0
- package/dist/extraction/tree-sitter-helpers.d.ts +28 -0
- package/dist/extraction/tree-sitter-helpers.d.ts.map +1 -0
- package/dist/extraction/tree-sitter-helpers.js +103 -0
- package/dist/extraction/tree-sitter-helpers.js.map +1 -0
- package/dist/extraction/tree-sitter-types.d.ts +193 -0
- package/dist/extraction/tree-sitter-types.d.ts.map +1 -0
- package/dist/extraction/tree-sitter-types.js +10 -0
- package/dist/extraction/tree-sitter-types.js.map +1 -0
- package/dist/extraction/tree-sitter.d.ts +317 -0
- package/dist/extraction/tree-sitter.d.ts.map +1 -0
- package/dist/extraction/tree-sitter.js +3092 -0
- package/dist/extraction/tree-sitter.js.map +1 -0
- package/dist/extraction/vue-extractor.d.ts +51 -0
- package/dist/extraction/vue-extractor.d.ts.map +1 -0
- package/dist/extraction/vue-extractor.js +251 -0
- package/dist/extraction/vue-extractor.js.map +1 -0
- package/dist/extraction/wasm/tree-sitter-lua.wasm +0 -0
- package/dist/extraction/wasm/tree-sitter-luau.wasm +0 -0
- package/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
- package/dist/extraction/wasm/tree-sitter-scala.wasm +0 -0
- package/dist/extraction/wasm-runtime-flags.d.ts +38 -0
- package/dist/extraction/wasm-runtime-flags.d.ts.map +1 -0
- package/dist/extraction/wasm-runtime-flags.js +106 -0
- package/dist/extraction/wasm-runtime-flags.js.map +1 -0
- package/dist/graph/index.d.ts +8 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +13 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/graph/queries.d.ts +106 -0
- package/dist/graph/queries.d.ts.map +1 -0
- package/dist/graph/queries.js +366 -0
- package/dist/graph/queries.js.map +1 -0
- package/dist/graph/traversal.d.ts +127 -0
- package/dist/graph/traversal.d.ts.map +1 -0
- package/dist/graph/traversal.js +531 -0
- package/dist/graph/traversal.js.map +1 -0
- package/dist/index.d.ts +551 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1165 -0
- package/dist/index.js.map +1 -0
- package/dist/installer/config-writer.d.ts +28 -0
- package/dist/installer/config-writer.d.ts.map +1 -0
- package/dist/installer/config-writer.js +91 -0
- package/dist/installer/config-writer.js.map +1 -0
- package/dist/installer/index.d.ts +92 -0
- package/dist/installer/index.d.ts.map +1 -0
- package/dist/installer/index.js +416 -0
- package/dist/installer/index.js.map +1 -0
- package/dist/installer/instructions-template.d.ts +35 -0
- package/dist/installer/instructions-template.d.ts.map +1 -0
- package/dist/installer/instructions-template.js +51 -0
- package/dist/installer/instructions-template.js.map +1 -0
- package/dist/installer/targets/claude.d.ts +117 -0
- package/dist/installer/targets/claude.d.ts.map +1 -0
- package/dist/installer/targets/claude.js +736 -0
- package/dist/installer/targets/claude.js.map +1 -0
- package/dist/installer/targets/registry.d.ts +19 -0
- package/dist/installer/targets/registry.d.ts.map +1 -0
- package/dist/installer/targets/registry.js +31 -0
- package/dist/installer/targets/registry.js.map +1 -0
- package/dist/installer/targets/shared.d.ts +76 -0
- package/dist/installer/targets/shared.d.ts.map +1 -0
- package/dist/installer/targets/shared.js +256 -0
- package/dist/installer/targets/shared.js.map +1 -0
- package/dist/installer/targets/types.d.ts +84 -0
- package/dist/installer/targets/types.d.ts.map +1 -0
- package/dist/installer/targets/types.js +12 -0
- package/dist/installer/targets/types.js.map +1 -0
- package/dist/isolation/worktree.d.ts +65 -0
- package/dist/isolation/worktree.d.ts.map +1 -0
- package/dist/isolation/worktree.js +231 -0
- package/dist/isolation/worktree.js.map +1 -0
- package/dist/mcp/daemon-paths.d.ts +46 -0
- package/dist/mcp/daemon-paths.d.ts.map +1 -0
- package/dist/mcp/daemon-paths.js +125 -0
- package/dist/mcp/daemon-paths.js.map +1 -0
- package/dist/mcp/daemon.d.ts +161 -0
- package/dist/mcp/daemon.d.ts.map +1 -0
- package/dist/mcp/daemon.js +403 -0
- package/dist/mcp/daemon.js.map +1 -0
- package/dist/mcp/designer-tools.d.ts +33 -0
- package/dist/mcp/designer-tools.d.ts.map +1 -0
- package/dist/mcp/designer-tools.js +313 -0
- package/dist/mcp/designer-tools.js.map +1 -0
- package/dist/mcp/engine.d.ts +105 -0
- package/dist/mcp/engine.d.ts.map +1 -0
- package/dist/mcp/engine.js +270 -0
- package/dist/mcp/engine.js.map +1 -0
- package/dist/mcp/index.d.ts +112 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +477 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/proxy.d.ts +81 -0
- package/dist/mcp/proxy.d.ts.map +1 -0
- package/dist/mcp/proxy.js +510 -0
- package/dist/mcp/proxy.js.map +1 -0
- package/dist/mcp/server-instructions.d.ts +18 -0
- package/dist/mcp/server-instructions.d.ts.map +1 -0
- package/dist/mcp/server-instructions.js +77 -0
- package/dist/mcp/server-instructions.js.map +1 -0
- package/dist/mcp/session.d.ts +77 -0
- package/dist/mcp/session.d.ts.map +1 -0
- package/dist/mcp/session.js +294 -0
- package/dist/mcp/session.js.map +1 -0
- package/dist/mcp/spec-tools.d.ts +39 -0
- package/dist/mcp/spec-tools.d.ts.map +1 -0
- package/dist/mcp/spec-tools.js +326 -0
- package/dist/mcp/spec-tools.js.map +1 -0
- package/dist/mcp/tools.d.ts +404 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +3087 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/mcp/transport.d.ts +188 -0
- package/dist/mcp/transport.d.ts.map +1 -0
- package/dist/mcp/transport.js +343 -0
- package/dist/mcp/transport.js.map +1 -0
- package/dist/mcp/version.d.ts +19 -0
- package/dist/mcp/version.d.ts.map +1 -0
- package/dist/mcp/version.js +71 -0
- package/dist/mcp/version.js.map +1 -0
- package/dist/resolution/callback-synthesizer.d.ts +10 -0
- package/dist/resolution/callback-synthesizer.d.ts.map +1 -0
- package/dist/resolution/callback-synthesizer.js +1300 -0
- package/dist/resolution/callback-synthesizer.js.map +1 -0
- package/dist/resolution/frameworks/cargo-workspace.d.ts +18 -0
- package/dist/resolution/frameworks/cargo-workspace.d.ts.map +1 -0
- package/dist/resolution/frameworks/cargo-workspace.js +225 -0
- package/dist/resolution/frameworks/cargo-workspace.js.map +1 -0
- package/dist/resolution/frameworks/csharp.d.ts +8 -0
- package/dist/resolution/frameworks/csharp.d.ts.map +1 -0
- package/dist/resolution/frameworks/csharp.js +241 -0
- package/dist/resolution/frameworks/csharp.js.map +1 -0
- package/dist/resolution/frameworks/drupal.d.ts +51 -0
- package/dist/resolution/frameworks/drupal.d.ts.map +1 -0
- package/dist/resolution/frameworks/drupal.js +367 -0
- package/dist/resolution/frameworks/drupal.js.map +1 -0
- package/dist/resolution/frameworks/expo-modules.d.ts +3 -0
- package/dist/resolution/frameworks/expo-modules.d.ts.map +1 -0
- package/dist/resolution/frameworks/expo-modules.js +143 -0
- package/dist/resolution/frameworks/expo-modules.js.map +1 -0
- package/dist/resolution/frameworks/express.d.ts +8 -0
- package/dist/resolution/frameworks/express.d.ts.map +1 -0
- package/dist/resolution/frameworks/express.js +308 -0
- package/dist/resolution/frameworks/express.js.map +1 -0
- package/dist/resolution/frameworks/fabric.d.ts +3 -0
- package/dist/resolution/frameworks/fabric.d.ts.map +1 -0
- package/dist/resolution/frameworks/fabric.js +354 -0
- package/dist/resolution/frameworks/fabric.js.map +1 -0
- package/dist/resolution/frameworks/go.d.ts +8 -0
- package/dist/resolution/frameworks/go.d.ts.map +1 -0
- package/dist/resolution/frameworks/go.js +161 -0
- package/dist/resolution/frameworks/go.js.map +1 -0
- package/dist/resolution/frameworks/index.d.ts +48 -0
- package/dist/resolution/frameworks/index.d.ts.map +1 -0
- package/dist/resolution/frameworks/index.js +161 -0
- package/dist/resolution/frameworks/index.js.map +1 -0
- package/dist/resolution/frameworks/java.d.ts +8 -0
- package/dist/resolution/frameworks/java.d.ts.map +1 -0
- package/dist/resolution/frameworks/java.js +504 -0
- package/dist/resolution/frameworks/java.js.map +1 -0
- package/dist/resolution/frameworks/laravel.d.ts +13 -0
- package/dist/resolution/frameworks/laravel.d.ts.map +1 -0
- package/dist/resolution/frameworks/laravel.js +257 -0
- package/dist/resolution/frameworks/laravel.js.map +1 -0
- package/dist/resolution/frameworks/nestjs.d.ts +26 -0
- package/dist/resolution/frameworks/nestjs.d.ts.map +1 -0
- package/dist/resolution/frameworks/nestjs.js +698 -0
- package/dist/resolution/frameworks/nestjs.js.map +1 -0
- package/dist/resolution/frameworks/play.d.ts +19 -0
- package/dist/resolution/frameworks/play.d.ts.map +1 -0
- package/dist/resolution/frameworks/play.js +111 -0
- package/dist/resolution/frameworks/play.js.map +1 -0
- package/dist/resolution/frameworks/python.d.ts +10 -0
- package/dist/resolution/frameworks/python.d.ts.map +1 -0
- package/dist/resolution/frameworks/python.js +396 -0
- package/dist/resolution/frameworks/python.js.map +1 -0
- package/dist/resolution/frameworks/react-native.d.ts +3 -0
- package/dist/resolution/frameworks/react-native.d.ts.map +1 -0
- package/dist/resolution/frameworks/react-native.js +360 -0
- package/dist/resolution/frameworks/react-native.js.map +1 -0
- package/dist/resolution/frameworks/react.d.ts +8 -0
- package/dist/resolution/frameworks/react.d.ts.map +1 -0
- package/dist/resolution/frameworks/react.js +365 -0
- package/dist/resolution/frameworks/react.js.map +1 -0
- package/dist/resolution/frameworks/ruby.d.ts +8 -0
- package/dist/resolution/frameworks/ruby.d.ts.map +1 -0
- package/dist/resolution/frameworks/ruby.js +302 -0
- package/dist/resolution/frameworks/ruby.js.map +1 -0
- package/dist/resolution/frameworks/rust.d.ts +8 -0
- package/dist/resolution/frameworks/rust.d.ts.map +1 -0
- package/dist/resolution/frameworks/rust.js +304 -0
- package/dist/resolution/frameworks/rust.js.map +1 -0
- package/dist/resolution/frameworks/svelte.d.ts +9 -0
- package/dist/resolution/frameworks/svelte.d.ts.map +1 -0
- package/dist/resolution/frameworks/svelte.js +249 -0
- package/dist/resolution/frameworks/svelte.js.map +1 -0
- package/dist/resolution/frameworks/swift-objc.d.ts +37 -0
- package/dist/resolution/frameworks/swift-objc.d.ts.map +1 -0
- package/dist/resolution/frameworks/swift-objc.js +252 -0
- package/dist/resolution/frameworks/swift-objc.js.map +1 -0
- package/dist/resolution/frameworks/swift.d.ts +10 -0
- package/dist/resolution/frameworks/swift.d.ts.map +1 -0
- package/dist/resolution/frameworks/swift.js +400 -0
- package/dist/resolution/frameworks/swift.js.map +1 -0
- package/dist/resolution/frameworks/vue.d.ts +9 -0
- package/dist/resolution/frameworks/vue.d.ts.map +1 -0
- package/dist/resolution/frameworks/vue.js +306 -0
- package/dist/resolution/frameworks/vue.js.map +1 -0
- package/dist/resolution/go-module.d.ts +26 -0
- package/dist/resolution/go-module.d.ts.map +1 -0
- package/dist/resolution/go-module.js +78 -0
- package/dist/resolution/go-module.js.map +1 -0
- package/dist/resolution/import-resolver.d.ts +68 -0
- package/dist/resolution/import-resolver.d.ts.map +1 -0
- package/dist/resolution/import-resolver.js +1275 -0
- package/dist/resolution/import-resolver.js.map +1 -0
- package/dist/resolution/index.d.ts +117 -0
- package/dist/resolution/index.d.ts.map +1 -0
- package/dist/resolution/index.js +895 -0
- package/dist/resolution/index.js.map +1 -0
- package/dist/resolution/lru-cache.d.ts +24 -0
- package/dist/resolution/lru-cache.d.ts.map +1 -0
- package/dist/resolution/lru-cache.js +62 -0
- package/dist/resolution/lru-cache.js.map +1 -0
- package/dist/resolution/name-matcher.d.ts +32 -0
- package/dist/resolution/name-matcher.d.ts.map +1 -0
- package/dist/resolution/name-matcher.js +596 -0
- package/dist/resolution/name-matcher.js.map +1 -0
- package/dist/resolution/path-aliases.d.ts +68 -0
- package/dist/resolution/path-aliases.d.ts.map +1 -0
- package/dist/resolution/path-aliases.js +238 -0
- package/dist/resolution/path-aliases.js.map +1 -0
- package/dist/resolution/spec-link-resolver.d.ts +103 -0
- package/dist/resolution/spec-link-resolver.d.ts.map +1 -0
- package/dist/resolution/spec-link-resolver.js +259 -0
- package/dist/resolution/spec-link-resolver.js.map +1 -0
- package/dist/resolution/strip-comments.d.ts +27 -0
- package/dist/resolution/strip-comments.d.ts.map +1 -0
- package/dist/resolution/strip-comments.js +441 -0
- package/dist/resolution/strip-comments.js.map +1 -0
- package/dist/resolution/swift-objc-bridge.d.ts +134 -0
- package/dist/resolution/swift-objc-bridge.d.ts.map +1 -0
- package/dist/resolution/swift-objc-bridge.js +256 -0
- package/dist/resolution/swift-objc-bridge.js.map +1 -0
- package/dist/resolution/types.d.ts +216 -0
- package/dist/resolution/types.d.ts.map +1 -0
- package/dist/resolution/types.js +8 -0
- package/dist/resolution/types.js.map +1 -0
- package/dist/resolution/workspace-packages.d.ts +48 -0
- package/dist/resolution/workspace-packages.d.ts.map +1 -0
- package/dist/resolution/workspace-packages.js +208 -0
- package/dist/resolution/workspace-packages.js.map +1 -0
- package/dist/search/query-parser.d.ts +57 -0
- package/dist/search/query-parser.d.ts.map +1 -0
- package/dist/search/query-parser.js +177 -0
- package/dist/search/query-parser.js.map +1 -0
- package/dist/search/query-utils.d.ts +71 -0
- package/dist/search/query-utils.d.ts.map +1 -0
- package/dist/search/query-utils.js +380 -0
- package/dist/search/query-utils.js.map +1 -0
- package/dist/server/cli.js +152 -0
- package/dist/server/index.js +12 -0
- package/dist/server/ingest/index.js +18 -0
- package/dist/server/ingest/ingestor.js +506 -0
- package/dist/server/ingest/parser.js +104 -0
- package/dist/server/ingest/pricing.js +78 -0
- package/dist/server/ingest/types.js +9 -0
- package/dist/server/ingest/watcher.js +77 -0
- package/dist/server/package.json +3 -0
- package/dist/server/project-registry.js +101 -0
- package/dist/server/routes/claude.js +868 -0
- package/dist/server/routes/graph.js +211 -0
- package/dist/server/routes/memory.js +272 -0
- package/dist/server/routes/projects.js +197 -0
- package/dist/server/routes/spec.js +265 -0
- package/dist/server/routes/status.js +112 -0
- package/dist/server/routes/workflow.js +212 -0
- package/dist/server/server.js +206 -0
- package/dist/server/static-handler.js +87 -0
- package/dist/sync/git-hooks.d.ts +45 -0
- package/dist/sync/git-hooks.d.ts.map +1 -0
- package/dist/sync/git-hooks.js +225 -0
- package/dist/sync/git-hooks.js.map +1 -0
- package/dist/sync/index.d.ts +19 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +35 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/watch-policy.d.ts +48 -0
- package/dist/sync/watch-policy.d.ts.map +1 -0
- package/dist/sync/watch-policy.js +124 -0
- package/dist/sync/watch-policy.js.map +1 -0
- package/dist/sync/watcher.d.ts +283 -0
- package/dist/sync/watcher.d.ts.map +1 -0
- package/dist/sync/watcher.js +606 -0
- package/dist/sync/watcher.js.map +1 -0
- package/dist/sync/worktree.d.ts +54 -0
- package/dist/sync/worktree.d.ts.map +1 -0
- package/dist/sync/worktree.js +137 -0
- package/dist/sync/worktree.js.map +1 -0
- package/dist/types.d.ts +623 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +108 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/glyphs.d.ts +42 -0
- package/dist/ui/glyphs.d.ts.map +1 -0
- package/dist/ui/glyphs.js +78 -0
- package/dist/ui/glyphs.js.map +1 -0
- package/dist/ui/shimmer-progress.d.ts +11 -0
- package/dist/ui/shimmer-progress.d.ts.map +1 -0
- package/dist/ui/shimmer-progress.js +90 -0
- package/dist/ui/shimmer-progress.js.map +1 -0
- package/dist/ui/shimmer-worker.d.ts +2 -0
- package/dist/ui/shimmer-worker.d.ts.map +1 -0
- package/dist/ui/shimmer-worker.js +118 -0
- package/dist/ui/shimmer-worker.js.map +1 -0
- package/dist/ui/types.d.ts +17 -0
- package/dist/ui/types.d.ts.map +1 -0
- package/dist/ui/types.js +3 -0
- package/dist/ui/types.js.map +1 -0
- package/dist/utils.d.ts +205 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +549 -0
- package/dist/utils.js.map +1 -0
- package/dist/web/chunk-2AJCHB7P.js +1 -0
- package/dist/web/chunk-2CPLUFCH.js +2 -0
- package/dist/web/chunk-2GBEK2GM.js +1 -0
- package/dist/web/chunk-2I7L37NS.js +1 -0
- package/dist/web/chunk-2NAWAJB5.js +1 -0
- package/dist/web/chunk-2OJBIPE4.js +1 -0
- package/dist/web/chunk-2YUJNZ2Y.js +6 -0
- package/dist/web/chunk-3E2WB6D5.js +1 -0
- package/dist/web/chunk-3EBFYSCH.js +2 -0
- package/dist/web/chunk-3QCQ4BXS.js +1 -0
- package/dist/web/chunk-42XVAQ6I.js +1 -0
- package/dist/web/chunk-45QHGCB4.js +17 -0
- package/dist/web/chunk-4IMMPEYM.js +1 -0
- package/dist/web/chunk-4TJQJPCZ.js +1 -0
- package/dist/web/chunk-4WZIHTPC.js +1 -0
- package/dist/web/chunk-4YVSYOSD.js +1 -0
- package/dist/web/chunk-5BQIOYKW.js +1 -0
- package/dist/web/chunk-5HGWHUJA.js +1 -0
- package/dist/web/chunk-5Y244R4G.js +1 -0
- package/dist/web/chunk-6RRDPT5Z.js +1 -0
- package/dist/web/chunk-6VKB2ZWM.js +1 -0
- package/dist/web/chunk-7DMFVTU4.js +1 -0
- package/dist/web/chunk-7RNS77UP.js +1 -0
- package/dist/web/chunk-7SMPKVEP.js +1 -0
- package/dist/web/chunk-A5R3MJMO.js +1 -0
- package/dist/web/chunk-ASZ77FMZ.js +1 -0
- package/dist/web/chunk-AZJVTPLU.js +1 -0
- package/dist/web/chunk-B3YPFY6A.js +1 -0
- package/dist/web/chunk-BLBRMCN2.js +1 -0
- package/dist/web/chunk-BMIAXD2V.js +2 -0
- package/dist/web/chunk-BUXWEHIY.js +1 -0
- package/dist/web/chunk-BYZFQSM6.js +1 -0
- package/dist/web/chunk-D5OCNEJA.js +2 -0
- package/dist/web/chunk-DLQPZWSI.css +1 -0
- package/dist/web/chunk-DTRN7FZR.js +1 -0
- package/dist/web/chunk-DYRFLPJA.js +1 -0
- package/dist/web/chunk-E3J3CXR5.js +1 -0
- package/dist/web/chunk-E44X4RH2.js +1 -0
- package/dist/web/chunk-E73OX2P7.js +1 -0
- package/dist/web/chunk-EAXRKDLV.js +1 -0
- package/dist/web/chunk-EBKKDHYI.js +1 -0
- package/dist/web/chunk-EE7V7Q5P.js +1 -0
- package/dist/web/chunk-EKY2FUHU.js +1 -0
- package/dist/web/chunk-EMGMOEVR.js +1 -0
- package/dist/web/chunk-EP6XOPXH.js +1 -0
- package/dist/web/chunk-ESGDLJOJ.js +1 -0
- package/dist/web/chunk-ETJG7NCY.js +1 -0
- package/dist/web/chunk-EUUEFEDI.js +1 -0
- package/dist/web/chunk-FGNZDHTL.js +11 -0
- package/dist/web/chunk-FHZHD2ZG.js +1 -0
- package/dist/web/chunk-FIJW2UNJ.js +1 -0
- package/dist/web/chunk-FMV5PXRC.js +5 -0
- package/dist/web/chunk-G7VZT5KB.js +3 -0
- package/dist/web/chunk-GR72OOCN.js +1 -0
- package/dist/web/chunk-GRZYXPSO.js +7 -0
- package/dist/web/chunk-GWPVKJIY.js +1 -0
- package/dist/web/chunk-GYGPS3AN.js +1 -0
- package/dist/web/chunk-H7AF7YS4.js +1 -0
- package/dist/web/chunk-HDZDQILN.js +1 -0
- package/dist/web/chunk-HMK6UO6N.js +1 -0
- package/dist/web/chunk-HZA6NEAB.js +1 -0
- package/dist/web/chunk-IHEE5NYJ.js +1 -0
- package/dist/web/chunk-ISNEBICW.js +1 -0
- package/dist/web/chunk-J2GZVLHH.js +1 -0
- package/dist/web/chunk-JTFXTIPE.js +903 -0
- package/dist/web/chunk-L37MTFSG.js +3 -0
- package/dist/web/chunk-LB6JPLX2.js +1 -0
- package/dist/web/chunk-LNSVDHCI.js +1 -0
- package/dist/web/chunk-LVGIY3SO.js +1 -0
- package/dist/web/chunk-LXLHIHEN.js +1 -0
- package/dist/web/chunk-MC4DFIHG.js +1 -0
- package/dist/web/chunk-MVOMVPYB.js +1 -0
- package/dist/web/chunk-N6SS4G6S.js +1 -0
- package/dist/web/chunk-NTBJG6SJ.js +1 -0
- package/dist/web/chunk-NUDB3Q2Y.js +3 -0
- package/dist/web/chunk-NZEZCT65.js +1 -0
- package/dist/web/chunk-OXEF5E3E.js +1 -0
- package/dist/web/chunk-PDN6QYGJ.js +4 -0
- package/dist/web/chunk-PGGJPDJG.js +1 -0
- package/dist/web/chunk-PUYSJNJR.js +1 -0
- package/dist/web/chunk-Q2RVFS45.js +1 -0
- package/dist/web/chunk-Q7L6LLAK.js +1 -0
- package/dist/web/chunk-QCMKJIWY.js +1 -0
- package/dist/web/chunk-QH6CF3M3.js +1 -0
- package/dist/web/chunk-QQ5LD7PI.js +1 -0
- package/dist/web/chunk-QR6L3KAC.js +1 -0
- package/dist/web/chunk-R2DLK4HO.js +1 -0
- package/dist/web/chunk-R5W2MDZN.js +1 -0
- package/dist/web/chunk-RD6TVPOT.js +1 -0
- package/dist/web/chunk-RKY4EJYJ.js +1 -0
- package/dist/web/chunk-RONYWVY7.js +1 -0
- package/dist/web/chunk-RXKXYF2C.js +1 -0
- package/dist/web/chunk-SBWU7JFC.js +1 -0
- package/dist/web/chunk-SEXBRGYK.js +1 -0
- package/dist/web/chunk-SHPTC4RL.js +1 -0
- package/dist/web/chunk-SUZYBYDW.js +1 -0
- package/dist/web/chunk-SWKJRNYY.js +1 -0
- package/dist/web/chunk-T66XVKGB.js +1 -0
- package/dist/web/chunk-T7AZ65JP.js +1 -0
- package/dist/web/chunk-TCZDVOHD.js +1 -0
- package/dist/web/chunk-TPTITA3V.js +1 -0
- package/dist/web/chunk-TR335633.js +1 -0
- package/dist/web/chunk-UBOZGQNK.js +1 -0
- package/dist/web/chunk-UR5KDXPX.js +1 -0
- package/dist/web/chunk-UR6O2GEH.js +1 -0
- package/dist/web/chunk-UTNMGWTP.js +1 -0
- package/dist/web/chunk-UYC52MBC.js +1 -0
- package/dist/web/chunk-VECWMHJP.js +1 -0
- package/dist/web/chunk-VUACT35R.js +3 -0
- package/dist/web/chunk-VZI7H4SZ.js +1 -0
- package/dist/web/chunk-WAI2JMZP.js +1 -0
- package/dist/web/chunk-WB6YHOD4.js +1 -0
- package/dist/web/chunk-WBT64AWV.js +1 -0
- package/dist/web/chunk-WCKHQIYN.js +1 -0
- package/dist/web/chunk-WDU3WICG.js +1 -0
- package/dist/web/chunk-WFXJIXZE.js +4 -0
- package/dist/web/chunk-WLIMNDS3.js +1 -0
- package/dist/web/chunk-WTGYRH3Z.js +298 -0
- package/dist/web/chunk-WXTCVDTP.js +1 -0
- package/dist/web/chunk-X2HTISHL.js +1 -0
- package/dist/web/chunk-XCDHWLVH.js +1 -0
- package/dist/web/chunk-Y3H6FFUZ.js +1 -0
- package/dist/web/chunk-Y4F5ULGJ.js +1 -0
- package/dist/web/chunk-YAMRN47K.js +2 -0
- package/dist/web/chunk-YEGKAAEE.js +1 -0
- package/dist/web/chunk-YM2KU57F.js +1 -0
- package/dist/web/chunk-YRERBP6T.js +1 -0
- package/dist/web/chunk-ZLV4VCDG.js +3 -0
- package/dist/web/chunk-ZTVI5KFF.js +1 -0
- package/dist/web/favicon-16.png +0 -0
- package/dist/web/favicon-180.png +0 -0
- package/dist/web/favicon-32.png +0 -0
- package/dist/web/favicon-512.png +0 -0
- package/dist/web/favicon-small.svg +15 -0
- package/dist/web/favicon.ico +0 -0
- package/dist/web/favicon.svg +20 -0
- package/dist/web/index.html +145 -0
- package/dist/web/main-ESADRXN2.css +1 -0
- package/dist/web/main-R53HA54V.js +1 -0
- package/dist/web/media/codicon-LN6W7LCM.ttf +0 -0
- package/dist/web/styles-KSOPUVDA.css +1 -0
- package/dist/web/sw.js +69 -0
- package/dist/workflows/condition-evaluator.d.ts +75 -0
- package/dist/workflows/condition-evaluator.d.ts.map +1 -0
- package/dist/workflows/condition-evaluator.js +282 -0
- package/dist/workflows/condition-evaluator.js.map +1 -0
- package/dist/workflows/defaults/claude-design-implement.yaml +336 -0
- package/dist/workflows/defaults/index.d.ts +26 -0
- package/dist/workflows/defaults/index.d.ts.map +1 -0
- package/dist/workflows/defaults/index.js +94 -0
- package/dist/workflows/defaults/index.js.map +1 -0
- package/dist/workflows/defaults/spec-author.yaml +214 -0
- package/dist/workflows/defaults/spec-fix.yaml +110 -0
- package/dist/workflows/defaults/spec-implement.yaml +150 -0
- package/dist/workflows/defaults/spec-relink.yaml +81 -0
- package/dist/workflows/defaults/spec-verify.yaml +51 -0
- package/dist/workflows/discovery.d.ts +46 -0
- package/dist/workflows/discovery.d.ts.map +1 -0
- package/dist/workflows/discovery.js +193 -0
- package/dist/workflows/discovery.js.map +1 -0
- package/dist/workflows/executor.d.ts +83 -0
- package/dist/workflows/executor.d.ts.map +1 -0
- package/dist/workflows/executor.js +624 -0
- package/dist/workflows/executor.js.map +1 -0
- package/dist/workflows/runners/approval.d.ts +18 -0
- package/dist/workflows/runners/approval.d.ts.map +1 -0
- package/dist/workflows/runners/approval.js +34 -0
- package/dist/workflows/runners/approval.js.map +1 -0
- package/dist/workflows/runners/bash.d.ts +13 -0
- package/dist/workflows/runners/bash.d.ts.map +1 -0
- package/dist/workflows/runners/bash.js +143 -0
- package/dist/workflows/runners/bash.js.map +1 -0
- package/dist/workflows/runners/cancel.d.ts +10 -0
- package/dist/workflows/runners/cancel.d.ts.map +1 -0
- package/dist/workflows/runners/cancel.js +19 -0
- package/dist/workflows/runners/cancel.js.map +1 -0
- package/dist/workflows/runners/prompt.d.ts +28 -0
- package/dist/workflows/runners/prompt.d.ts.map +1 -0
- package/dist/workflows/runners/prompt.js +212 -0
- package/dist/workflows/runners/prompt.js.map +1 -0
- package/dist/workflows/runners/script.d.ts +17 -0
- package/dist/workflows/runners/script.d.ts.map +1 -0
- package/dist/workflows/runners/script.js +155 -0
- package/dist/workflows/runners/script.js.map +1 -0
- package/dist/workflows/runners/types.d.ts +51 -0
- package/dist/workflows/runners/types.d.ts.map +1 -0
- package/dist/workflows/runners/types.js +13 -0
- package/dist/workflows/runners/types.js.map +1 -0
- package/dist/workflows/schemas/workflow.d.ts +166 -0
- package/dist/workflows/schemas/workflow.d.ts.map +1 -0
- package/dist/workflows/schemas/workflow.js +437 -0
- package/dist/workflows/schemas/workflow.js.map +1 -0
- package/hooks/hooks.json +11 -0
- package/package.json +7 -3
- package/scripts/offline-install.sh +19 -6
- package/selectors.json +41 -0
- /package/commands/{cg-explore.md → ss-explore.md} +0 -0
- /package/commands/{cg-impact.md → ss-impact.md} +0 -0
- /package/commands/{cg-relink.md → ss-relink.md} +0 -0
- /package/commands/{cg-spec.md → ss-spec.md} +0 -0
- /package/commands/{cg-sync.md → ss-sync.md} +0 -0
- /package/commands/{cg-trace.md → ss-trace.md} +0 -0
|
@@ -0,0 +1,1413 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.DesignerController = exports.SESSION_URL_RE = void 0;
|
|
7
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
10
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
11
|
+
const browser_1 = require("./browser");
|
|
12
|
+
const artifact_store_1 = require("./artifact-store");
|
|
13
|
+
const session_store_1 = require("./session-store");
|
|
14
|
+
const repo_root_1 = require("./repo-root");
|
|
15
|
+
const cdp_ensure_1 = require("./cdp-ensure");
|
|
16
|
+
const run_state_1 = require("./run-state");
|
|
17
|
+
const oopif_reader_1 = require("./oopif-reader");
|
|
18
|
+
const preview_host_1 = require("./preview-host");
|
|
19
|
+
const cdp_env_1 = require("./cdp-env");
|
|
20
|
+
const interstitials_1 = require("./interstitials");
|
|
21
|
+
const file_panel_1 = require("./file-panel");
|
|
22
|
+
const fflate_1 = require("fflate");
|
|
23
|
+
const DESIGN_HOME = 'https://claude.ai/design';
|
|
24
|
+
// A claude.ai/design session URL: /design/p/<uuid>. Capture group 1 is the
|
|
25
|
+
// project id. Used by isInSession()-style checks and `adopt` (binding an
|
|
26
|
+
// already-open project tab to a key, bypassing the create-flow home).
|
|
27
|
+
exports.SESSION_URL_RE = /^https:\/\/claude\.ai\/design\/p\/([a-f0-9-]+)/i;
|
|
28
|
+
// Appended to every designer_prompt payload. The live MCP surface
|
|
29
|
+
// (listFiles / openFile / newFiles diff) scrapes a flat root from the
|
|
30
|
+
// file panel; files nested under folders stay invisible until handoff.
|
|
31
|
+
// Enforcing flat layout here keeps the live flow honest. Users who genuinely
|
|
32
|
+
// want nested layouts should explicitly contradict this in their prompt and
|
|
33
|
+
// rely on `designer_handoff` for authoritative file access.
|
|
34
|
+
const FLAT_LAYOUT_SUFFIX = '\n\nFile layout: keep all generated files at the project root. No subfolders.';
|
|
35
|
+
const DECISIVE_SUFFIX = '\n\nIf you would otherwise stop to ask clarifying questions, do not. Choose the most defensible answer for each axis yourself and proceed. Note your assumption in a one-line `<!-- assumed: ... -->` comment at the top of the relevant file so I can override on the next turn.';
|
|
36
|
+
function loadSelectors() {
|
|
37
|
+
const base = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(repo_root_1.REPO_ROOT, 'selectors.json'), 'utf8'));
|
|
38
|
+
const overridePath = node_path_1.default.join(node_os_1.default.homedir(), '.designer', 'selectors.override.json');
|
|
39
|
+
if (node_fs_1.default.existsSync(overridePath)) {
|
|
40
|
+
try {
|
|
41
|
+
return deepMerge(base, JSON.parse(node_fs_1.default.readFileSync(overridePath, 'utf8')));
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
console.warn(`[designer] failed to parse ${overridePath}: ${e.message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return base;
|
|
48
|
+
}
|
|
49
|
+
function deepMerge(a, b) {
|
|
50
|
+
if (Array.isArray(a) || Array.isArray(b))
|
|
51
|
+
return b ?? a;
|
|
52
|
+
if (typeof a !== 'object' || typeof b !== 'object' || !a || !b)
|
|
53
|
+
return b ?? a;
|
|
54
|
+
const out = { ...a };
|
|
55
|
+
for (const k of Object.keys(b))
|
|
56
|
+
out[k] = deepMerge(a[k], b[k]);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
class DesignerController {
|
|
60
|
+
key;
|
|
61
|
+
selectors;
|
|
62
|
+
browser;
|
|
63
|
+
_preSendHtml = '';
|
|
64
|
+
constructor({ key, headed = true } = {}) {
|
|
65
|
+
this.key = key || 'default';
|
|
66
|
+
this.selectors = loadSelectors();
|
|
67
|
+
this.browser = (0, browser_1.createBrowser)({ session: `designer-${this.key}`, headed });
|
|
68
|
+
}
|
|
69
|
+
async currentUrl() {
|
|
70
|
+
return (await this.browser.url().catch(() => '')) || '';
|
|
71
|
+
}
|
|
72
|
+
async isOnHome() {
|
|
73
|
+
const u = await this.currentUrl();
|
|
74
|
+
return /\/design\/?$/.test(u) || u.endsWith('/design');
|
|
75
|
+
}
|
|
76
|
+
async isInSession() {
|
|
77
|
+
const u = await this.currentUrl();
|
|
78
|
+
return /\/design\/p\/[a-f0-9-]+/i.test(u);
|
|
79
|
+
}
|
|
80
|
+
async getStatus() {
|
|
81
|
+
const stored = (0, session_store_1.getSession)(this.key);
|
|
82
|
+
const url = await this.currentUrl();
|
|
83
|
+
const inSession = /\/design\/p\/[a-f0-9-]+/i.test(url);
|
|
84
|
+
const availableFiles = inSession ? await this.listFiles().catch(() => []) : [];
|
|
85
|
+
const awaitingClarification = inSession ? await this.detectAwaitingClarification() : false;
|
|
86
|
+
return {
|
|
87
|
+
key: this.key,
|
|
88
|
+
stored,
|
|
89
|
+
currentUrl: url,
|
|
90
|
+
inSession,
|
|
91
|
+
onHome: /\/design\/?$/.test(url) || url.endsWith('/design'),
|
|
92
|
+
availableFiles,
|
|
93
|
+
awaitingClarification
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// Heuristic only. The questions popover is ephemeral, but the teaser text stays
|
|
97
|
+
// in the chat turn body. If the most recent turn is from Claude and contains the
|
|
98
|
+
// teaser, treat the session as blocked on a clarification. Returns false if the
|
|
99
|
+
// page is mid-stream (we'd race the textnode walk against React's commit phase).
|
|
100
|
+
async detectAwaitingClarification() {
|
|
101
|
+
const turns = await this.getChatTurns().catch(() => []);
|
|
102
|
+
if (turns.length === 0)
|
|
103
|
+
return false;
|
|
104
|
+
const last = turns[turns.length - 1];
|
|
105
|
+
if (!last || last.role !== 'assistant')
|
|
106
|
+
return false;
|
|
107
|
+
return /Claude has some questions/i.test(last.text);
|
|
108
|
+
}
|
|
109
|
+
async session({ action = 'status', name, fidelity = 'wireframe' } = {}) {
|
|
110
|
+
if (action === 'status')
|
|
111
|
+
return this.getStatus();
|
|
112
|
+
if (action === 'ensure_ready') {
|
|
113
|
+
const r = await this.ensureReady();
|
|
114
|
+
return { ...r, status: await this.getStatus() };
|
|
115
|
+
}
|
|
116
|
+
if (action === 'clear') {
|
|
117
|
+
// clearInterstitials acts on the currently-bound CDP tab; in a multi-key
|
|
118
|
+
// workflow that may be a DIFFERENT project. Select THIS key's stored tab
|
|
119
|
+
// first (scoped, activate-only — no navigation, so it won't hijack another
|
|
120
|
+
// key's tab) so the clear targets the requested session (PR #77 Codex P2).
|
|
121
|
+
if ((0, cdp_env_1.isCdpEnabled)())
|
|
122
|
+
await (0, cdp_ensure_1.ensureCdpUp)();
|
|
123
|
+
const picked = await this.selectMatchingTab().catch(() => ({ matched: false, candidates: 0 }));
|
|
124
|
+
// candidates===0 means NO tab matches this key — selectMatchingTab didn't
|
|
125
|
+
// activate anything, so the browser is still bound to whatever was active.
|
|
126
|
+
// Refuse rather than clear (click/reload) an unrelated key's tab (PR #77
|
|
127
|
+
// Codex P2). candidates>0 means this key's tab is bound (matched, or
|
|
128
|
+
// present-but-masked by the very interstitial we're here to clear) → proceed.
|
|
129
|
+
if (picked.candidates === 0) {
|
|
130
|
+
const report = { ok: true, handled: [], blocked: null };
|
|
131
|
+
return { ...report, matched: false, note: 'no live tab matches this key — nothing to clear', status: await this.getStatus() };
|
|
132
|
+
}
|
|
133
|
+
const r = await this.clearInterstitials();
|
|
134
|
+
return { ...r, status: await this.getStatus() };
|
|
135
|
+
}
|
|
136
|
+
if (action === 'resume') {
|
|
137
|
+
const stored = (0, session_store_1.getSession)(this.key);
|
|
138
|
+
if (!stored?.designUrl)
|
|
139
|
+
throw new Error(`No stored session for key=${this.key}. Use action='create' with a name.`);
|
|
140
|
+
const r = await this.resumeSession();
|
|
141
|
+
return { ...r, status: await this.getStatus() };
|
|
142
|
+
}
|
|
143
|
+
if (action === 'create') {
|
|
144
|
+
if (!name)
|
|
145
|
+
throw new Error("action='create' requires a name.");
|
|
146
|
+
const r = await this.createSession(name, fidelity);
|
|
147
|
+
return { ...r, status: await this.getStatus() };
|
|
148
|
+
}
|
|
149
|
+
if (action === 'adopt') {
|
|
150
|
+
const r = await this.adoptSession(name);
|
|
151
|
+
return { ...r, status: await this.getStatus() };
|
|
152
|
+
}
|
|
153
|
+
throw new Error(`Unknown action: ${action}`);
|
|
154
|
+
}
|
|
155
|
+
// Bind a project you opened by hand (a live /design/p/<uuid> tab) to this
|
|
156
|
+
// key — the supported path around the redesigned creation-cards home, whose
|
|
157
|
+
// anchors drift wholesale (issue #61). `name` is optional metadata only.
|
|
158
|
+
//
|
|
159
|
+
// Safety (PR #66 review): adopt must never silently bind the WRONG project.
|
|
160
|
+
// With more than one /design/p/<uuid> tab open (normal during parallel --key
|
|
161
|
+
// work), there's no key↔tab correlation to pick the right one, so refuse and
|
|
162
|
+
// list them rather than guess by active-first. We also bind from the VALIDATED
|
|
163
|
+
// candidate URL, not a currentUrl() re-read after activateTab (which could race
|
|
164
|
+
// to a different tab).
|
|
165
|
+
async adoptSession(name) {
|
|
166
|
+
await (0, cdp_ensure_1.ensureCdpUp)();
|
|
167
|
+
const candidates = await this.candidateTabs((u) => exports.SESSION_URL_RE.test(u));
|
|
168
|
+
if (candidates.length > 1) {
|
|
169
|
+
const list = candidates.map((t) => ` - ${t.url}`).join('\n');
|
|
170
|
+
throw new Error(`adopt can't choose among ${candidates.length} open /design/p/<uuid> tabs:\n${list}\n` +
|
|
171
|
+
`Leave only the target project open (close the others), then retry — adopt won't guess which one this key (${this.key}) means.`);
|
|
172
|
+
}
|
|
173
|
+
// Use the validated candidate URL; fall back to the already-bound tab when no
|
|
174
|
+
// dedicated session tab is open (agent-browser may already be on a /p/ URL).
|
|
175
|
+
const top = candidates[0];
|
|
176
|
+
const url = top?.url || (await this.currentUrl());
|
|
177
|
+
const m = url.match(exports.SESSION_URL_RE);
|
|
178
|
+
if (!m) {
|
|
179
|
+
throw new Error(`No /design/p/<uuid> tab to adopt — open a project by hand in the CDP-attached Chrome first. current url=${url || 'none'}`);
|
|
180
|
+
}
|
|
181
|
+
// Bind agent-browser to the adopted tab for subsequent prompt/handoff. If
|
|
182
|
+
// activation races or fails, the stored designUrl (from the validated URL
|
|
183
|
+
// above) is still correct — ensureReady re-binds by it later.
|
|
184
|
+
if (top)
|
|
185
|
+
await this.browser.activateTab(top.index).catch(() => null);
|
|
186
|
+
const designUrl = url.split('?')[0] || url;
|
|
187
|
+
const uuid = m[1] ?? '';
|
|
188
|
+
(0, session_store_1.upsertSession)(this.key, { designUrl, lastUrl: url, ...(name ? { name } : {}) });
|
|
189
|
+
(0, session_store_1.appendHistory)(this.key, { kind: 'session_adopt', url: designUrl, ...(name ? { name } : {}) });
|
|
190
|
+
return { ok: true, url: designUrl, uuid, adopted: true, ...(name ? { name } : {}) };
|
|
191
|
+
}
|
|
192
|
+
// Page tabs whose URL satisfies `match`, ordered active-first then by index
|
|
193
|
+
// ascending — the candidate ordering both adoptSession and selectMatchingTab
|
|
194
|
+
// rely on. Degrades to [] if the CDP tabs() call fails.
|
|
195
|
+
async candidateTabs(match) {
|
|
196
|
+
const tabs = await this.browser.tabs().catch(() => []);
|
|
197
|
+
return tabs
|
|
198
|
+
.filter((t) => t.type === 'page' && t.url && match(t.url))
|
|
199
|
+
.sort((a, b) => Number(b.active) - Number(a.active) || a.index - b.index);
|
|
200
|
+
}
|
|
201
|
+
// Pick the live claude.ai/design tab among possibly many CDP pages, switch
|
|
202
|
+
// agent-browser's binding to it, and verify readiness via DOM anchors.
|
|
203
|
+
// Returns the count of candidates considered (for error messaging).
|
|
204
|
+
async selectMatchingTab() {
|
|
205
|
+
const stored = (0, session_store_1.getSession)(this.key);
|
|
206
|
+
const targetRoot = stored?.designUrl?.split('?')[0];
|
|
207
|
+
const candidates = await this.candidateTabs((u) => targetRoot ? u.startsWith(targetRoot) : /^https:\/\/claude\.ai\/design(\/|$|\?)/.test(u));
|
|
208
|
+
if (candidates.length === 0)
|
|
209
|
+
return { matched: false, candidates: 0 };
|
|
210
|
+
for (const cand of candidates) {
|
|
211
|
+
await this.browser.activateTab(cand.index).catch(() => null);
|
|
212
|
+
const composerOk = await this.browser.isVisible(this.selectors.composer.promptTextarea).catch(() => false);
|
|
213
|
+
const homeOk = this.selectors.login.signedInIndicator
|
|
214
|
+
? await this.browser.isVisible(this.selectors.login.signedInIndicator).catch(() => false)
|
|
215
|
+
: false;
|
|
216
|
+
if (composerOk || homeOk) {
|
|
217
|
+
(0, session_store_1.upsertSession)(this.key, { lastUrl: await this.currentUrl() });
|
|
218
|
+
return { matched: true, candidates: candidates.length };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { matched: false, candidates: candidates.length };
|
|
222
|
+
}
|
|
223
|
+
// --- interstitial pre-flight (see interstitials.ts) -----------------------
|
|
224
|
+
// claude.ai/design interrupts the flow with content-only overlays that carry
|
|
225
|
+
// no data-testid: the 495k-token "Continue here" banner, a transient "Something
|
|
226
|
+
// went wrong" page, and the Cloudflare bot-check. Verbs run clearInterstitials()
|
|
227
|
+
// through ensureReady so these don't silently stall automation or get misread
|
|
228
|
+
// as a finished / context-ceilinged generation.
|
|
229
|
+
// Read the page content the classifier needs in a single eval, via the shared
|
|
230
|
+
// INTERSTITIAL_PROBE_EXPR (same shape as the CI diagnostic). Returns null when
|
|
231
|
+
// the read FAILS — distinct from a successfully-read clear page — so callers
|
|
232
|
+
// never mistake "couldn't read" for "no interstitial" (review #5a).
|
|
233
|
+
async _probeInterstitial() {
|
|
234
|
+
return this.browser.evalValue(interstitials_1.INTERSTITIAL_PROBE_EXPR).catch(() => null);
|
|
235
|
+
}
|
|
236
|
+
// The configured token-banner button text, threaded into every classify call so
|
|
237
|
+
// detection and the click stay on one source of truth (review #3b).
|
|
238
|
+
get _classifyOpts() {
|
|
239
|
+
return { continueHere: this.selectors.interstitials?.continueHere };
|
|
240
|
+
}
|
|
241
|
+
// Classify the page now. Returns null on an unreadable page (probe failure) OR
|
|
242
|
+
// a clear page — callers that must distinguish the two re-probe explicitly.
|
|
243
|
+
async _classifyNow() {
|
|
244
|
+
const probe = await this._probeInterstitial();
|
|
245
|
+
return probe ? (0, interstitials_1.classifyInterstitial)(probe, this._classifyOpts) : null;
|
|
246
|
+
}
|
|
247
|
+
// Detect and clear interstitials on the currently-bound tab. Loops because
|
|
248
|
+
// clearing one can reveal another (a reload can land back on the token banner),
|
|
249
|
+
// and each action re-probes to CONFIRM before counting it handled. Blocking
|
|
250
|
+
// kinds (cloudflare, transient-error) that survive are reported `blocked`; the
|
|
251
|
+
// token banner is non-blocking (the shell stays usable) so it never blocks a
|
|
252
|
+
// verb even if its button can't be clicked (review #3 / #6). In CDP mode,
|
|
253
|
+
// ensureCdpUp first so the standalone `designer clear` fails loud on a dead
|
|
254
|
+
// Chrome instead of a false recovery (review #5b) — but GATE it on isCdpEnabled
|
|
255
|
+
// so the documented DESIGNER_CDP='' opt-out (where ensureCdpUp throws by design)
|
|
256
|
+
// still works: the probe/click/reload run over agent-browser, not CDP, so the
|
|
257
|
+
// clear itself needs no CDP. Without this gate, the createSession pre-flight
|
|
258
|
+
// would break `create` in the opt-out flow (PR #77 Codex P2).
|
|
259
|
+
async clearInterstitials({ maxPasses = 4, cloudflareWaitMs = 25_000, pollMs = 1500 } = {}) {
|
|
260
|
+
if ((0, cdp_env_1.isCdpEnabled)())
|
|
261
|
+
await (0, cdp_ensure_1.ensureCdpUp)();
|
|
262
|
+
const handled = [];
|
|
263
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
264
|
+
const kind = await this._classifyNow();
|
|
265
|
+
if (!kind)
|
|
266
|
+
return { ok: true, handled, blocked: null };
|
|
267
|
+
const action = (0, interstitials_1.plannedAction)(kind);
|
|
268
|
+
if (action === 'click-continue') {
|
|
269
|
+
// Benign banner: try to dismiss, but never block the verb on it.
|
|
270
|
+
const text = this.selectors.interstitials?.continueHere || interstitials_1.CONTINUE_HERE_TEXT;
|
|
271
|
+
const clicked = await this._clickButtonByText(new RegExp(`^${escapeRegExp(text)}$`, 'i')).catch(() => false);
|
|
272
|
+
if (clicked) {
|
|
273
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
274
|
+
if ((await this._classifyNow()) !== kind) {
|
|
275
|
+
handled.push(kind);
|
|
276
|
+
(0, session_store_1.appendHistory)(this.key, { kind: 'interstitial', interstitial: kind, action });
|
|
277
|
+
continue; // cleared — loop to catch any newly-revealed interstitial
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
(0, session_store_1.appendHistory)(this.key, {
|
|
281
|
+
kind: 'interstitial',
|
|
282
|
+
interstitial: kind,
|
|
283
|
+
action: clicked ? 'uncleared-nonblocking' : 'continue-button-missing'
|
|
284
|
+
});
|
|
285
|
+
return { ok: true, handled, blocked: null };
|
|
286
|
+
}
|
|
287
|
+
if (action === 'reload') {
|
|
288
|
+
const u = await this.currentUrl();
|
|
289
|
+
if (!u)
|
|
290
|
+
break; // can't reload an unknown URL (review #4) — fall to residual
|
|
291
|
+
(0, session_store_1.appendHistory)(this.key, { kind: 'interstitial', interstitial: kind, action });
|
|
292
|
+
await this.browser.open(u).catch(() => null);
|
|
293
|
+
// 'load', not 'networkidle' — the SPA's persistent connections never go
|
|
294
|
+
// idle, so networkidle would burn the full timeout each pass (review #4).
|
|
295
|
+
await this.browser.waitLoad('load').catch(() => null);
|
|
296
|
+
await new Promise((r) => setTimeout(r, 800));
|
|
297
|
+
continue; // confirm on the next pass's probe
|
|
298
|
+
}
|
|
299
|
+
if (action === 'await-human') {
|
|
300
|
+
// Cloudflare can't be solved programmatically; wait for it to self-clear
|
|
301
|
+
// before declaring it blocked — it frequently resolves on its own.
|
|
302
|
+
const cleared = await this._waitForInterstitialClear(kind, cloudflareWaitMs, pollMs);
|
|
303
|
+
(0, session_store_1.appendHistory)(this.key, { kind: 'interstitial', interstitial: kind, action: cleared ? 'cleared-after-wait' : 'blocked' });
|
|
304
|
+
if (cleared) {
|
|
305
|
+
handled.push(kind);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
return { ok: false, handled, blocked: kind };
|
|
309
|
+
}
|
|
310
|
+
// Exhaustiveness: a new InterstitialAction must be handled above, not fall
|
|
311
|
+
// silently into one of the branches (review below-gate).
|
|
312
|
+
const _exhaustive = action;
|
|
313
|
+
return _exhaustive;
|
|
314
|
+
}
|
|
315
|
+
// maxPasses exhausted (e.g. a transient error that survived every reload).
|
|
316
|
+
const residual = await this._classifyNow();
|
|
317
|
+
if (residual && (0, interstitials_1.isBlockingInterstitial)(residual))
|
|
318
|
+
return { ok: false, handled, blocked: residual };
|
|
319
|
+
return { ok: true, handled, blocked: null };
|
|
320
|
+
}
|
|
321
|
+
async _waitForInterstitialClear(kind, timeoutMs, pollMs) {
|
|
322
|
+
const start = Date.now();
|
|
323
|
+
while (Date.now() - start < timeoutMs) {
|
|
324
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
325
|
+
const probe = await this._probeInterstitial();
|
|
326
|
+
// A FAILED read (null probe) is NOT "cleared" — keep waiting (review #5a).
|
|
327
|
+
// Only a successful read that classifies as a different kind (or clear)
|
|
328
|
+
// means the challenge is gone; the outer loop handles whatever's now on top.
|
|
329
|
+
if (probe && (0, interstitials_1.classifyInterstitial)(probe, this._classifyOpts) !== kind) {
|
|
330
|
+
return true;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
_interstitialError(kind, candidates) {
|
|
336
|
+
const suffix = candidates > 0 ? ` (checked ${candidates} tab(s))` : '';
|
|
337
|
+
if (kind === 'cloudflare') {
|
|
338
|
+
return new Error(`Cloudflare bot-check is up on claude.ai/design and didn't clear${suffix}. ` +
|
|
339
|
+
`Solve it in the CDP-attached Chrome, then retry.`);
|
|
340
|
+
}
|
|
341
|
+
return new Error(`Unresolved interstitial '${kind}' on claude.ai/design${suffix}.`);
|
|
342
|
+
}
|
|
343
|
+
async ensureReady() {
|
|
344
|
+
await (0, cdp_ensure_1.ensureCdpUp)();
|
|
345
|
+
const picked = await this.selectMatchingTab();
|
|
346
|
+
if (picked.matched) {
|
|
347
|
+
// The token banner leaves the composer visible, so a tab can match with an
|
|
348
|
+
// interstitial still up — clear it before any verb runs against the page.
|
|
349
|
+
const interstitials = await this.clearInterstitials();
|
|
350
|
+
if (interstitials.blocked)
|
|
351
|
+
throw this._interstitialError(interstitials.blocked, picked.candidates);
|
|
352
|
+
return { ok: true, url: await this.currentUrl(), inSession: await this.isInSession(), interstitials };
|
|
353
|
+
}
|
|
354
|
+
// selectMatchingTab matches on a visible composer/home anchor — but a
|
|
355
|
+
// transient-error or Cloudflare overlay HIDES those anchors, so a real design
|
|
356
|
+
// tab can be masked. Before falling back to opening home, activate the best
|
|
357
|
+
// design tab and try clearing; a successful clear re-exposes the anchors.
|
|
358
|
+
//
|
|
359
|
+
// SCOPE to the stored project (mirror selectMatchingTab): an unscoped /design
|
|
360
|
+
// filter would activate the lowest-index design tab — potentially an UNRELATED
|
|
361
|
+
// project — and a later clear/fall-through could silently bind this key to it
|
|
362
|
+
// (review #2, cross-project contamination). Only widen to any /design tab when
|
|
363
|
+
// this key has no stored project to be wrong about.
|
|
364
|
+
const recoveryRoot = (0, session_store_1.getSession)(this.key)?.designUrl?.split('?')[0];
|
|
365
|
+
const designTabs = await this.candidateTabs((u) => recoveryRoot ? u.startsWith(recoveryRoot) : /^https:\/\/claude\.ai\/design(\/|$|\?)/.test(u));
|
|
366
|
+
const recoveryTab = designTabs[0];
|
|
367
|
+
if (recoveryTab) {
|
|
368
|
+
await this.browser.activateTab(recoveryTab.index).catch(() => null);
|
|
369
|
+
const report = await this.clearInterstitials();
|
|
370
|
+
if (report.blocked)
|
|
371
|
+
throw this._interstitialError(report.blocked, designTabs.length);
|
|
372
|
+
if (report.handled.length > 0) {
|
|
373
|
+
const retry = await this.selectMatchingTab();
|
|
374
|
+
if (retry.matched) {
|
|
375
|
+
return { ok: true, url: await this.currentUrl(), inSession: await this.isInSession(), interstitials: report };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// No live design tab matched. Fall back to opening home and re-checking.
|
|
380
|
+
if (picked.candidates === 0) {
|
|
381
|
+
const u = await this.currentUrl();
|
|
382
|
+
if (!/claude\.ai\/design/.test(u)) {
|
|
383
|
+
await this.browser.open(DESIGN_HOME);
|
|
384
|
+
await this.browser.waitLoad('networkidle').catch(() => null);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
const interstitials = await this.clearInterstitials();
|
|
388
|
+
if (interstitials.blocked)
|
|
389
|
+
throw this._interstitialError(interstitials.blocked, picked.candidates);
|
|
390
|
+
const homeOk = this.selectors.login.signedInIndicator
|
|
391
|
+
? await this.browser.isVisible(this.selectors.login.signedInIndicator).catch(() => false)
|
|
392
|
+
: false;
|
|
393
|
+
const sessionOk = await this.browser.isVisible(this.selectors.composer.promptTextarea).catch(() => false);
|
|
394
|
+
if (!homeOk && !sessionOk) {
|
|
395
|
+
const suffix = picked.candidates > 0 ? ` (checked ${picked.candidates} tab(s))` : '';
|
|
396
|
+
throw new Error(`Not signed in to claude.ai/design, or on an unrecognized page${suffix}. Sign in in the CDP-attached Chrome.`);
|
|
397
|
+
}
|
|
398
|
+
(0, session_store_1.upsertSession)(this.key, { lastUrl: await this.currentUrl() });
|
|
399
|
+
return { ok: true, url: await this.currentUrl(), inSession: await this.isInSession(), interstitials };
|
|
400
|
+
}
|
|
401
|
+
async createSession(name, fidelity = 'wireframe', { timeoutMs = 20 * 60_000, stabilityMs = 4000 } = {}) {
|
|
402
|
+
// 2026-06 redesign (#61): the home is composer-driven. There's no longer a
|
|
403
|
+
// project-name input or a wireframe/high-fi toggle — you seed an intent in
|
|
404
|
+
// the chat composer (`home.creator`, the same data-testid as the in-session
|
|
405
|
+
// composer) and click "Start project" (`home.createButton`, the same
|
|
406
|
+
// data-testid as the in-session send button). So `name` becomes the seed
|
|
407
|
+
// prompt. The redesign removed the wireframe/high-fi toggle, so `fidelity` is
|
|
408
|
+
// folded into the seed as a directive (and still stored) — otherwise highfi
|
|
409
|
+
// and wireframe creates would behave identically while the session claimed a
|
|
410
|
+
// fidelity that was never applied (#66 review). The creation-type cards
|
|
411
|
+
// (Slides / Prototype / Product wireframe / …) are text-only buttons left as a
|
|
412
|
+
// follow-up. Verified live against the redesigned home.
|
|
413
|
+
//
|
|
414
|
+
// `name` is the composer seed, so it must be non-empty — a whitespace-only
|
|
415
|
+
// name leaves the send button disabled and would otherwise spin the full
|
|
416
|
+
// navigation poll before failing with a misleading message.
|
|
417
|
+
if (!name?.trim())
|
|
418
|
+
throw new Error('create requires a non-empty name (used as the project seed prompt).');
|
|
419
|
+
await this.browser.open(DESIGN_HOME);
|
|
420
|
+
await this.browser.waitLoad('networkidle').catch(() => null);
|
|
421
|
+
// createSession opens home directly (not via ensureReady), so run the same
|
|
422
|
+
// interstitial pre-flight — a Cloudflare check or transient error on home
|
|
423
|
+
// would otherwise stall waitFor(creator) with a misleading timeout.
|
|
424
|
+
const interstitials = await this.clearInterstitials();
|
|
425
|
+
if (interstitials.blocked)
|
|
426
|
+
throw this._interstitialError(interstitials.blocked, 0);
|
|
427
|
+
await this.browser.waitFor(this.selectors.home.creator);
|
|
428
|
+
const fidelityHint = fidelity === 'highfi'
|
|
429
|
+
? '\n\nBuild this as a high-fidelity, visually polished design.'
|
|
430
|
+
: '\n\nBuild this as a low-fidelity wireframe.';
|
|
431
|
+
// The seed IS the first generation now, so apply the same flat-layout contract
|
|
432
|
+
// sendPrompt() appends to every prompt — otherwise the create run can produce
|
|
433
|
+
// nested folders the flat live file-list/openFile scrape can't see (#66 review).
|
|
434
|
+
const seed = name + fidelityHint + FLAT_LAYOUT_SUFFIX;
|
|
435
|
+
this._preSendHtml = '';
|
|
436
|
+
// The composer-create kicks off a real generation. Return only once it has
|
|
437
|
+
// settled, using the same network-first completion signal as iterate(), so the
|
|
438
|
+
// documented next step (`designer prompt`) can't interleave with the create run
|
|
439
|
+
// (#66 review). Honors the DESIGNER_CDP='' opt-out: with no observer we can't
|
|
440
|
+
// wait reliably (the HTML waiter is degraded under the bootstrap iframe), so we
|
|
441
|
+
// navigate and proceed best-effort — the next prompt's send-enable wait resyncs.
|
|
442
|
+
//
|
|
443
|
+
// Bind the observer to THIS exact home tab (findDesignTarget exact-matches the
|
|
444
|
+
// URL). The home URL is a prefix of every /design/p/<uuid> tab, so a loose
|
|
445
|
+
// prefix could otherwise bind to a different project's tab in multi-tab/
|
|
446
|
+
// parallel-key workflows (#66). The tab keeps its CDP target across the SPA
|
|
447
|
+
// navigation to /p/, so the observer follows it.
|
|
448
|
+
const cdpEnabled = (0, cdp_env_1.isCdpEnabled)();
|
|
449
|
+
const homeUrl = await this.currentUrl();
|
|
450
|
+
let observer = cdpEnabled
|
|
451
|
+
? await run_state_1.RunStateObserver.attach({ preferUrlPrefix: homeUrl })
|
|
452
|
+
: null;
|
|
453
|
+
try {
|
|
454
|
+
observer?.beginRun();
|
|
455
|
+
// Reuse the battle-tested composer fill+submit (contenteditable ProseMirror;
|
|
456
|
+
// waits for the send button to enable before clicking "Start project").
|
|
457
|
+
await this._submitPrompt(seed);
|
|
458
|
+
let inSession = false;
|
|
459
|
+
for (let i = 0; i < 60; i++) {
|
|
460
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
461
|
+
if ((inSession = await this.isInSession()))
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
464
|
+
if (!inSession)
|
|
465
|
+
throw new Error('Project creation did not navigate to a /p/ url in time.');
|
|
466
|
+
// Wait for the seed generation to finish. Tolerate observer-lost/timeout —
|
|
467
|
+
// the project exists either way; don't fail create over an imperfect wait.
|
|
468
|
+
if (observer)
|
|
469
|
+
await this._waitForGenerationDoneNetwork(observer, { timeoutMs, stabilityMs }).catch(() => null);
|
|
470
|
+
}
|
|
471
|
+
finally {
|
|
472
|
+
observer?.close();
|
|
473
|
+
observer = null;
|
|
474
|
+
}
|
|
475
|
+
const url = await this.currentUrl();
|
|
476
|
+
(0, session_store_1.upsertSession)(this.key, { designUrl: url, name, fidelity, lastUrl: url });
|
|
477
|
+
(0, session_store_1.appendHistory)(this.key, { kind: 'session_create', name, fidelity, url });
|
|
478
|
+
return { ok: true, url, name, fidelity };
|
|
479
|
+
}
|
|
480
|
+
async resumeSession() {
|
|
481
|
+
const stored = (0, session_store_1.getSession)(this.key);
|
|
482
|
+
if (!stored?.designUrl)
|
|
483
|
+
throw new Error(`No designUrl stored for key=${this.key}. Create one first.`);
|
|
484
|
+
await this.browser.open(stored.designUrl);
|
|
485
|
+
await this.browser.waitLoad('networkidle').catch(() => null);
|
|
486
|
+
return { ok: true, url: stored.designUrl };
|
|
487
|
+
}
|
|
488
|
+
async _submitPrompt(prompt) {
|
|
489
|
+
const { promptTextarea, sendButton } = this.selectors.composer;
|
|
490
|
+
await this.browser.waitFor(promptTextarea);
|
|
491
|
+
// The composer has shipped as both a React-controlled <textarea> and a
|
|
492
|
+
// ProseMirror contenteditable <div> — branch on what's actually there.
|
|
493
|
+
await this.browser.evalValue(`(() => {
|
|
494
|
+
const el = document.querySelector(${JSON.stringify(promptTextarea)});
|
|
495
|
+
if (!el) throw new Error('composer input not found');
|
|
496
|
+
const text = ${JSON.stringify(prompt)};
|
|
497
|
+
if (el instanceof HTMLTextAreaElement) {
|
|
498
|
+
// Bypass React's value ownership via the native setter, then fire a
|
|
499
|
+
// bubbling input event.
|
|
500
|
+
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
|
|
501
|
+
setter.call(el, text);
|
|
502
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
503
|
+
el.focus();
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
if (el.isContentEditable) {
|
|
507
|
+
// Deliver the text as a synthetic paste so the editor's own paste
|
|
508
|
+
// pipeline updates its internal state; execCommand('insertText')
|
|
509
|
+
// flattens multi-line prompts into one paragraph.
|
|
510
|
+
el.focus();
|
|
511
|
+
const sel = window.getSelection();
|
|
512
|
+
const range = document.createRange();
|
|
513
|
+
range.selectNodeContents(el);
|
|
514
|
+
sel.removeAllRanges();
|
|
515
|
+
sel.addRange(range);
|
|
516
|
+
const dt = new DataTransfer();
|
|
517
|
+
dt.setData('text/plain', text);
|
|
518
|
+
const unhandled = el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
|
|
519
|
+
if (unhandled) {
|
|
520
|
+
// No editor intercepted the paste — plain contenteditable fallback.
|
|
521
|
+
document.execCommand('insertText', false, text);
|
|
522
|
+
}
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
throw new Error('composer input is neither textarea nor contenteditable: ' + el.tagName);
|
|
526
|
+
})()`);
|
|
527
|
+
for (let i = 0; i < 30; i++) {
|
|
528
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
529
|
+
const disabled = await this.browser.evalValue(`(() => { const b = document.querySelector(${JSON.stringify(sendButton)}); return !b || b.disabled || b.getAttribute('aria-disabled') === 'true'; })()`);
|
|
530
|
+
if (!disabled)
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
await this.browser.evalValue(`(() => {
|
|
534
|
+
const b = document.querySelector(${JSON.stringify(sendButton)});
|
|
535
|
+
if (!b) throw new Error('send button not found');
|
|
536
|
+
b.click();
|
|
537
|
+
return true;
|
|
538
|
+
})()`);
|
|
539
|
+
}
|
|
540
|
+
async sendPrompt(prompt, { decisive = false, onBeforeSubmit } = {}) {
|
|
541
|
+
const before = await this.fetchServedHtml();
|
|
542
|
+
this._preSendHtml = before.html;
|
|
543
|
+
const effective = prompt + FLAT_LAYOUT_SUFFIX + (decisive ? DECISIVE_SUFFIX : '');
|
|
544
|
+
onBeforeSubmit?.();
|
|
545
|
+
await this._submitPrompt(effective);
|
|
546
|
+
const suffixApplied = decisive ? 'flat_layout+decisive' : 'flat_layout';
|
|
547
|
+
(0, session_store_1.appendHistory)(this.key, { kind: 'prompt', prompt, suffixApplied });
|
|
548
|
+
return { ok: true };
|
|
549
|
+
}
|
|
550
|
+
async waitForGenerationDone({ timeoutMs = 20 * 60_000, stabilityMs = 4000, pollMs = 1500 } = {}) {
|
|
551
|
+
return this._waitForGenerationDoneHtml({ timeoutMs, stabilityMs, pollMs });
|
|
552
|
+
}
|
|
553
|
+
async _waitForGenerationDoneHtml({ timeoutMs = 20 * 60_000, stabilityMs = 4000, pollMs = 1500 } = {}) {
|
|
554
|
+
// One reader for the whole poll loop (reused per poll), not one per poll (#67).
|
|
555
|
+
return this.withPreviewReader(async (readServed) => {
|
|
556
|
+
const start = Date.now();
|
|
557
|
+
const preHtml = this._preSendHtml || '';
|
|
558
|
+
let lastHtml = '';
|
|
559
|
+
let lastLen = -1;
|
|
560
|
+
let stableSince = 0;
|
|
561
|
+
let sawChange = false;
|
|
562
|
+
while (Date.now() - start < timeoutMs) {
|
|
563
|
+
const { html, src } = await readServed();
|
|
564
|
+
const len = html.length;
|
|
565
|
+
if (!preHtml) {
|
|
566
|
+
if (len > 0)
|
|
567
|
+
sawChange = true;
|
|
568
|
+
}
|
|
569
|
+
else if (html && html !== preHtml) {
|
|
570
|
+
sawChange = true;
|
|
571
|
+
}
|
|
572
|
+
if (sawChange) {
|
|
573
|
+
if (len === lastLen && html === lastHtml) {
|
|
574
|
+
if (!stableSince)
|
|
575
|
+
stableSince = Date.now();
|
|
576
|
+
if (Date.now() - stableSince > stabilityMs) {
|
|
577
|
+
const url = await this.currentUrl();
|
|
578
|
+
return { ok: true, elapsedMs: Date.now() - start, url, iframeSrc: src, htmlBytes: len, html };
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
stableSince = 0;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
lastHtml = html;
|
|
586
|
+
lastLen = len;
|
|
587
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
588
|
+
}
|
|
589
|
+
return { ok: false, error: 'timeout', elapsedMs: Date.now() - start };
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
async _waitForGenerationDoneNetwork(observer, { timeoutMs = 20 * 60_000, stabilityMs = 4000, pollMs = 1500 } = {}) {
|
|
593
|
+
const terminal = await observer.awaitTerminal({ hardTimeoutMs: timeoutMs });
|
|
594
|
+
if (terminal.terminal === 'observer-lost') {
|
|
595
|
+
return { ok: false, error: 'observer-lost', elapsedMs: terminal.elapsedMs, reason: terminal.reason };
|
|
596
|
+
}
|
|
597
|
+
if (terminal.terminal === 'blocked') {
|
|
598
|
+
return { ok: false, error: 'blocked', elapsedMs: terminal.elapsedMs, reason: terminal.reason };
|
|
599
|
+
}
|
|
600
|
+
if (terminal.terminal === 'timeout') {
|
|
601
|
+
return { ok: false, error: 'stalled', elapsedMs: terminal.elapsedMs, reason: terminal.reason };
|
|
602
|
+
}
|
|
603
|
+
// ReleaseTurn can lead served-HTML readiness by up to ~10s on small edits —
|
|
604
|
+
// the preview keeps propagating after the turn completes (trace findings:
|
|
605
|
+
// ReleaseTurn led HTML byte-stability by 5–10s on edit/tweak runs). So poll
|
|
606
|
+
// a bounded window for the preview to byte-stabilize instead of trusting the
|
|
607
|
+
// first fetch. Crucially we do NOT require a change from _preSendHtml: a
|
|
608
|
+
// chat-only run legitimately keeps it, and forcing a change would reintroduce
|
|
609
|
+
// the timeout blind spot this observer exists to fix.
|
|
610
|
+
// One reader for the whole settle loop (reused per poll), not one per poll (#67).
|
|
611
|
+
return this.withPreviewReader(async (readServed) => {
|
|
612
|
+
let { html, src } = await readServed();
|
|
613
|
+
const preHtml = this._preSendHtml || '';
|
|
614
|
+
const settleDeadline = Date.now() + Math.min(timeoutMs, Math.max(stabilityMs, 12_000));
|
|
615
|
+
let stableSince = Date.now();
|
|
616
|
+
while (Date.now() < settleDeadline) {
|
|
617
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
618
|
+
const next = await readServed();
|
|
619
|
+
if (next.html !== html) {
|
|
620
|
+
html = next.html;
|
|
621
|
+
src = next.src;
|
|
622
|
+
stableSince = Date.now();
|
|
623
|
+
}
|
|
624
|
+
else if (html !== preHtml && Date.now() - stableSince >= stabilityMs) {
|
|
625
|
+
break; // changed and now byte-stable for stabilityMs → settled
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
const url = await this.currentUrl();
|
|
629
|
+
return { ok: true, elapsedMs: terminal.elapsedMs, url, iframeSrc: src, htmlBytes: html.length, html };
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
async snapshotDesign({ html: knownHtml, iframeSrc: knownSrc } = {}) {
|
|
633
|
+
let iframeSrc = knownSrc || (await this.getIframeSrc());
|
|
634
|
+
let html = knownHtml ?? null;
|
|
635
|
+
if (html == null && (0, preview_host_1.isPreviewIframeSrc)(iframeSrc)) {
|
|
636
|
+
// Route through fetchServedHtml (OOPIF capture in the bootstrap regime, node
|
|
637
|
+
// fetch otherwise) so the snapshot command and iterate()'s post-gen snapshot
|
|
638
|
+
// get real HTML. NOTE: `iframeSrc` here is the iframe ELEMENT's src (the
|
|
639
|
+
// `_bootstrap` loader); the captured `html` is the OOPIF document
|
|
640
|
+
// (`/serve/<filename>`). They are intentionally not the same URL — `src` is
|
|
641
|
+
// the element locator, not a fetchable handle for `html` (#67 review #5).
|
|
642
|
+
const served = await this.fetchServedHtml();
|
|
643
|
+
if (served.html)
|
|
644
|
+
html = served.html;
|
|
645
|
+
if (served.src)
|
|
646
|
+
iframeSrc = served.src;
|
|
647
|
+
}
|
|
648
|
+
const dir = (0, artifact_store_1.sessionDir)(this.key);
|
|
649
|
+
const shotPath = node_path_1.default.join(dir, `shot-${Date.now()}.png`);
|
|
650
|
+
const shotOk = await this.browser
|
|
651
|
+
.screenshot(shotPath, { full: true })
|
|
652
|
+
.then(() => true)
|
|
653
|
+
.catch(() => false);
|
|
654
|
+
const url = await this.currentUrl();
|
|
655
|
+
return { html, screenshotPath: shotOk ? shotPath : null, url, iframeSrc };
|
|
656
|
+
}
|
|
657
|
+
async _ensureInSession() {
|
|
658
|
+
await this.ensureReady();
|
|
659
|
+
if (await this.isInSession())
|
|
660
|
+
return;
|
|
661
|
+
const stored = (0, session_store_1.getSession)(this.key);
|
|
662
|
+
if (!stored?.designUrl)
|
|
663
|
+
throw new Error(`No active session for key=${this.key}. Call createSession first.`);
|
|
664
|
+
await this.resumeSession();
|
|
665
|
+
// ensureReady's pre-flight cleared the home/current page, but this cold-start
|
|
666
|
+
// just navigated to the stored project — an interstitial on the PROJECT page
|
|
667
|
+
// itself (token banner, transient error, Cloudflare) would otherwise reach the
|
|
668
|
+
// verb that called us. Clear again on the resumed page (PR #77 Codex P2).
|
|
669
|
+
const interstitials = await this.clearInterstitials();
|
|
670
|
+
if (interstitials.blocked)
|
|
671
|
+
throw this._interstitialError(interstitials.blocked, 1);
|
|
672
|
+
}
|
|
673
|
+
async iterate(prompt, { file, timeoutMs, stabilityMs, decisive } = {}) {
|
|
674
|
+
await this._ensureInSession();
|
|
675
|
+
if (file)
|
|
676
|
+
await this.openFile(file);
|
|
677
|
+
const preFiles = await this.listFiles().catch(() => []);
|
|
678
|
+
const preChatCount = (await this.getChatTurns()).length;
|
|
679
|
+
const waitBudgetMs = timeoutMs ?? 20 * 60_000;
|
|
680
|
+
// Honor the documented CDP opt-out: DESIGNER_CDP='' means "use the
|
|
681
|
+
// agent-browser session-managed flow" (browser.ts resolves it the same way
|
|
682
|
+
// via ??). Attaching the observer would otherwise route an opted-out user
|
|
683
|
+
// through the CDP layer, which resolves '' to :9222 and can auto-launch the
|
|
684
|
+
// debug Chrome (ensureCdpUp). When disabled, fall through to the HTML waiter.
|
|
685
|
+
const cdpEnabled = (0, cdp_env_1.isCdpEnabled)();
|
|
686
|
+
let observer = cdpEnabled
|
|
687
|
+
? await run_state_1.RunStateObserver.attach({
|
|
688
|
+
preferUrlPrefix: (await this.currentUrl()).split('?')[0] || null
|
|
689
|
+
})
|
|
690
|
+
: null;
|
|
691
|
+
let done;
|
|
692
|
+
try {
|
|
693
|
+
await this.sendPrompt(prompt, { decisive, onBeforeSubmit: () => observer?.beginRun() });
|
|
694
|
+
if (observer) {
|
|
695
|
+
done = await this._waitForGenerationDoneNetwork(observer, { timeoutMs: waitBudgetMs, stabilityMs });
|
|
696
|
+
if (done.error === 'observer-lost') {
|
|
697
|
+
const fallback = await this._waitForGenerationDoneHtml({
|
|
698
|
+
timeoutMs: Math.max(1, waitBudgetMs - done.elapsedMs),
|
|
699
|
+
stabilityMs
|
|
700
|
+
});
|
|
701
|
+
done = { ...fallback, elapsedMs: done.elapsedMs + fallback.elapsedMs };
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
else {
|
|
705
|
+
done = await this._waitForGenerationDoneHtml({ timeoutMs: waitBudgetMs, stabilityMs });
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
finally {
|
|
709
|
+
observer?.close();
|
|
710
|
+
observer = null;
|
|
711
|
+
}
|
|
712
|
+
const postFiles = await this.listFiles().catch(() => []);
|
|
713
|
+
const postTurns = await this.getChatTurns();
|
|
714
|
+
const lastTurn = postTurns[postTurns.length - 1];
|
|
715
|
+
const chatReply = postTurns.length > preChatCount && lastTurn && lastTurn.role === 'assistant'
|
|
716
|
+
? lastTurn.text.replace(/^Claude(?:\n+)?/, '').trim()
|
|
717
|
+
: null;
|
|
718
|
+
const newFiles = postFiles.filter((f) => !preFiles.includes(f));
|
|
719
|
+
const removedFiles = preFiles.filter((f) => !postFiles.includes(f));
|
|
720
|
+
const snap = await this.snapshotDesign({ html: done.html, iframeSrc: done.iframeSrc });
|
|
721
|
+
const htmlHash = snap.html ? hashHex(snap.html) : null;
|
|
722
|
+
const activeFile = extractFileParam(snap.url);
|
|
723
|
+
let failureMode = null;
|
|
724
|
+
if (!done.ok) {
|
|
725
|
+
if (done.error === 'timeout')
|
|
726
|
+
failureMode = 'timeout';
|
|
727
|
+
else if (done.error === 'stalled')
|
|
728
|
+
failureMode = 'stalled';
|
|
729
|
+
else if (done.error === 'blocked')
|
|
730
|
+
failureMode = 'blocked';
|
|
731
|
+
else
|
|
732
|
+
failureMode = 'unstable';
|
|
733
|
+
}
|
|
734
|
+
else if (snap.html && snap.html === this._preSendHtml && newFiles.length === 0)
|
|
735
|
+
failureMode = 'no_change';
|
|
736
|
+
const fidelity = (0, session_store_1.getSession)(this.key)?.fidelity || null;
|
|
737
|
+
const record = (0, artifact_store_1.saveIteration)(this.key, {
|
|
738
|
+
prompt,
|
|
739
|
+
fidelity,
|
|
740
|
+
html: snap.html,
|
|
741
|
+
screenshotPath: snap.screenshotPath,
|
|
742
|
+
url: snap.url,
|
|
743
|
+
meta: { done: { ok: done.ok, elapsedMs: done.elapsedMs }, failureMode, activeFile, newFiles, htmlHash }
|
|
744
|
+
});
|
|
745
|
+
(0, session_store_1.appendHistory)(this.key, { kind: 'iteration', record: record.files, newFiles });
|
|
746
|
+
return {
|
|
747
|
+
done: { ok: done.ok, elapsedMs: done.elapsedMs, failureMode },
|
|
748
|
+
changed: !!(snap.html && snap.html !== this._preSendHtml) || newFiles.length > 0,
|
|
749
|
+
url: snap.url,
|
|
750
|
+
activeFile,
|
|
751
|
+
newFiles,
|
|
752
|
+
removedFiles,
|
|
753
|
+
htmlPath: record.files.html || null,
|
|
754
|
+
screenshotPath: record.files.screenshot || null,
|
|
755
|
+
htmlBytes: snap.html ? snap.html.length : 0,
|
|
756
|
+
htmlHash,
|
|
757
|
+
chatReply
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
async listProjects() {
|
|
761
|
+
await this.browser.open(DESIGN_HOME);
|
|
762
|
+
await this.browser.waitLoad('networkidle').catch(() => null);
|
|
763
|
+
await this.browser.waitFor(this.selectors.home.projectsList).catch(() => null);
|
|
764
|
+
const json = await this.browser.evalValue(`(() => {
|
|
765
|
+
// 2026-06 redesign (#61): there's no project-card data-testid. Each
|
|
766
|
+
// project is an <a href="/design/p/<uuid>"> with the project name as its
|
|
767
|
+
// text; dedupe by uuid (a card can wrap more than one anchor).
|
|
768
|
+
const links = Array.from(document.querySelectorAll('a[href*="/design/p/"]'));
|
|
769
|
+
const seen = new Set();
|
|
770
|
+
const out = [];
|
|
771
|
+
for (const a of links) {
|
|
772
|
+
const href = a.href || a.getAttribute('href') || '';
|
|
773
|
+
const m = href.match(/\\/design\\/p\\/([a-f0-9-]+)/i);
|
|
774
|
+
if (!m || seen.has(m[1])) continue;
|
|
775
|
+
seen.add(m[1]);
|
|
776
|
+
out.push({ name: (a.textContent || '').trim() || null, sub: null, url: href });
|
|
777
|
+
}
|
|
778
|
+
return out;
|
|
779
|
+
})()`).catch(() => []);
|
|
780
|
+
return Array.isArray(json) ? json : [];
|
|
781
|
+
}
|
|
782
|
+
async listFiles() {
|
|
783
|
+
const { files } = await this.listFilesDetailed();
|
|
784
|
+
return files;
|
|
785
|
+
}
|
|
786
|
+
// Returns top-level files + whether folders were detected in the panel.
|
|
787
|
+
// The live panel shows folders collapsed and doesn't expose an API we can
|
|
788
|
+
// auth against (/files endpoint is 401, no aria-expanded on rows, clicks
|
|
789
|
+
// don't expand programmatically). When folders are present, the caller
|
|
790
|
+
// should fall back to designer_handoff for an authoritative list.
|
|
791
|
+
async listFilesDetailed() {
|
|
792
|
+
// Navigate to THIS key's project if we're not already there. Being in
|
|
793
|
+
// any /p/ session isn't enough — a different key's files would be
|
|
794
|
+
// returned against the currently-visible project by mistake.
|
|
795
|
+
const stored = (0, session_store_1.getSession)(this.key);
|
|
796
|
+
const currentUrl = await this.currentUrl();
|
|
797
|
+
const targetRoot = stored?.designUrl?.split('?')[0];
|
|
798
|
+
const currentRoot = currentUrl.split('?')[0];
|
|
799
|
+
if (!targetRoot) {
|
|
800
|
+
throw new Error(`No designUrl stored for key=${this.key}. createSession or resumeSession first.`);
|
|
801
|
+
}
|
|
802
|
+
if (currentRoot !== targetRoot) {
|
|
803
|
+
await this.browser.open(stored.designUrl);
|
|
804
|
+
await this.browser.waitLoad('networkidle').catch(() => null);
|
|
805
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
806
|
+
}
|
|
807
|
+
// Open the Design Files dialog to get the richer file/folder listing, via the
|
|
808
|
+
// shared idempotent opener (file-panel.ts) — the SAME expression the
|
|
809
|
+
// session.fileListScrape health anchor uses, so the probe can't pass while
|
|
810
|
+
// this silently no-ops. It clicks the label (React root delegation; the old
|
|
811
|
+
// walk-up-for-non-null-.onclick never fired) and is OPEN-ONLY so the before/
|
|
812
|
+
// after listFiles() calls in iterate() don't toggle it shut mid-run (PR #77
|
|
813
|
+
// Codex P2).
|
|
814
|
+
await this.browser.evalValue(file_panel_1.OPEN_FILES_PANEL_EXPR).catch(() => null);
|
|
815
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
816
|
+
const result = await this.browser.evalValue(`(() => {
|
|
817
|
+
// Walk all text nodes — Claude's file panel wraps filenames in styled-
|
|
818
|
+
// component <div>s whose class hashes change across deploys. Tag-based
|
|
819
|
+
// scraping misses them; text-node walking is resilient.
|
|
820
|
+
const seen = new Set();
|
|
821
|
+
const files = [];
|
|
822
|
+
let designFilesLabelVisible = false;
|
|
823
|
+
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
|
|
824
|
+
let node;
|
|
825
|
+
while ((node = walker.nextNode())) {
|
|
826
|
+
const t = (node.textContent || '').trim();
|
|
827
|
+
if (t === 'Design Files') designFilesLabelVisible = true;
|
|
828
|
+
if (!/^[A-Za-z0-9 _.()\\-]+\\.(html|js|css|jsx|tsx|ts|md|json|svg)$/i.test(t)) continue;
|
|
829
|
+
if (t.length > 80 || seen.has(t)) continue;
|
|
830
|
+
seen.add(t);
|
|
831
|
+
files.push(t);
|
|
832
|
+
}
|
|
833
|
+
// Folders: rows whose sibling text is 'Folder' (a Claude-side label).
|
|
834
|
+
// Still tag-based since folder rows are structurally different —
|
|
835
|
+
// revisit if this breaks.
|
|
836
|
+
const folderSet = new Set();
|
|
837
|
+
const divs = Array.from(document.querySelectorAll('div'));
|
|
838
|
+
for (const d of divs) {
|
|
839
|
+
if (d.onclick === null) continue;
|
|
840
|
+
const lines = (d.innerText || '').trim().split('\\n').map((l) => l.trim());
|
|
841
|
+
if (lines.length >= 2 && lines[1] === 'Folder' && lines[0] && lines[0].length < 40) {
|
|
842
|
+
folderSet.add(lines[0]);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return { files, folders: Array.from(folderSet), designFilesLabelVisible };
|
|
846
|
+
})()`).catch(() => ({ files: [], folders: [], designFilesLabelVisible: false }));
|
|
847
|
+
const files = Array.isArray(result.files) ? result.files : [];
|
|
848
|
+
const folders = Array.isArray(result.folders) ? result.folders : [];
|
|
849
|
+
// Empty rail under a visible "Design Files" label means we scraped the
|
|
850
|
+
// wrong tab or the panel didn't open — don't tell callers it's truth.
|
|
851
|
+
const emptyButLabelVisible = files.length === 0 && result.designFilesLabelVisible === true;
|
|
852
|
+
return {
|
|
853
|
+
files,
|
|
854
|
+
folders,
|
|
855
|
+
authoritative: !emptyButLabelVisible && folders.length === 0
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
async openFile(filename) {
|
|
859
|
+
const stored = (0, session_store_1.getSession)(this.key);
|
|
860
|
+
const baseUrl = stored?.designUrl || (await this.currentUrl()).split('?')[0] || '';
|
|
861
|
+
if (!/\/design\/p\//.test(baseUrl))
|
|
862
|
+
throw new Error('No project open for this key.');
|
|
863
|
+
const wanted = encodeURIComponent(filename);
|
|
864
|
+
const fileParamOf = (u) => {
|
|
865
|
+
try {
|
|
866
|
+
return new URL(u).searchParams.get('file');
|
|
867
|
+
}
|
|
868
|
+
catch {
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
const targetRoot = baseUrl.split('?')[0];
|
|
873
|
+
// Already on THIS project's tab showing the requested file with a live
|
|
874
|
+
// preview — no swap needed (re-opening the same file, e.g. repeated
|
|
875
|
+
// `prompt --file X`). Must compare the project root too, not just the file
|
|
876
|
+
// param: with parallel keys, Chrome can be on project B's tab with the same
|
|
877
|
+
// ?file=index.html, and skipping the open would silently target B (#66).
|
|
878
|
+
const curUrl = await this.currentUrl();
|
|
879
|
+
const before = await this.getIframeSrc();
|
|
880
|
+
if (curUrl.split('?')[0] === targetRoot && fileParamOf(curUrl) === filename && (0, preview_host_1.isPreviewIframeSrc)(before)) {
|
|
881
|
+
return { ok: true, file: filename, url: curUrl };
|
|
882
|
+
}
|
|
883
|
+
const target = `${targetRoot}?file=${wanted}`;
|
|
884
|
+
await this.browser.open(target);
|
|
885
|
+
// Readiness across two UI generations — and the file-switch false-positive
|
|
886
|
+
// Codex flagged on #66:
|
|
887
|
+
// - legacy: the signed iframe src embedded the filename (src.includes(wanted)).
|
|
888
|
+
// - current (issue #61): EVERY file is served from the same per-project
|
|
889
|
+
// <uuid>.claudeusercontent.com/_bootstrap src — the filename is not in the
|
|
890
|
+
// src and there is no active-file DOM marker (verified live). So a present
|
|
891
|
+
// claudeusercontent iframe alone is NOT proof the requested file rendered:
|
|
892
|
+
// on a switch A→B the URL updates before React swaps, so the caller would
|
|
893
|
+
// otherwise be handed A's still-mounted preview while asking for B.
|
|
894
|
+
// Switching tears the iframe down (src → '') and remounts it (~1.2s). Require
|
|
895
|
+
// that teardown + restabilize, plus the URL carrying the requested file, before
|
|
896
|
+
// declaring success. `before === ''` means nothing was mounted (no stale preview
|
|
897
|
+
// to clear). Only HTML renders in the iframe; .css/.md/.js settle to an empty
|
|
898
|
+
// preview, so for those a torn-down-then-stable-empty state is the success signal.
|
|
899
|
+
const expectsPreview = /\.html?$/i.test(filename);
|
|
900
|
+
let sawTeardown = before === '';
|
|
901
|
+
let lastSrc = before;
|
|
902
|
+
for (let i = 0; i < 40; i++) {
|
|
903
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
904
|
+
const src = await this.getIframeSrc();
|
|
905
|
+
if (src.includes(wanted))
|
|
906
|
+
return { ok: true, file: filename, url: await this.currentUrl() };
|
|
907
|
+
if (src !== before)
|
|
908
|
+
sawTeardown = true;
|
|
909
|
+
const url = await this.currentUrl();
|
|
910
|
+
if (fileParamOf(url) === filename && sawTeardown && src === lastSrc) {
|
|
911
|
+
if (expectsPreview && (0, preview_host_1.isPreviewIframeSrc)(src))
|
|
912
|
+
return { ok: true, file: filename, url };
|
|
913
|
+
if (!expectsPreview && src === '')
|
|
914
|
+
return { ok: true, file: filename, url };
|
|
915
|
+
}
|
|
916
|
+
lastSrc = src;
|
|
917
|
+
}
|
|
918
|
+
// Window exhausted. For NON-HTML files an empty preview is the legitimate
|
|
919
|
+
// end-state, so accept once the URL took the requested file and the prior
|
|
920
|
+
// preview cleared. For HTML, never settling on a claudeusercontent iframe is a
|
|
921
|
+
// real failure — do NOT mask a non-rendering preview (a soft ok here would
|
|
922
|
+
// hand callers empty/stale content); fail loud with iframe-swap-timeout.
|
|
923
|
+
const url = await this.currentUrl();
|
|
924
|
+
if (!expectsPreview && fileParamOf(url) === filename && sawTeardown) {
|
|
925
|
+
return { ok: true, file: filename, url };
|
|
926
|
+
}
|
|
927
|
+
return { ok: false, error: 'iframe-swap-timeout', file: filename, url };
|
|
928
|
+
}
|
|
929
|
+
async fetchFile(filename) {
|
|
930
|
+
const swap = await this.openFile(filename);
|
|
931
|
+
if (!swap.ok)
|
|
932
|
+
return { ok: false, error: swap.error, file: filename, html: '', htmlBytes: 0 };
|
|
933
|
+
const { html, src } = await this.fetchServedHtml();
|
|
934
|
+
return { ok: true, file: filename, iframeSrc: src, html, htmlBytes: html.length };
|
|
935
|
+
}
|
|
936
|
+
async getChatTurns() {
|
|
937
|
+
return ((await this.browser
|
|
938
|
+
.evalValue(`(() => {
|
|
939
|
+
const c = document.querySelector('[data-testid="chat-messages"]');
|
|
940
|
+
const inner = c && c.children[0];
|
|
941
|
+
if (!inner) return [];
|
|
942
|
+
return Array.from(inner.children).map((d) => {
|
|
943
|
+
const txt = (d.innerText || '').trim();
|
|
944
|
+
// Role signal: Claude's replies carry a feedback widget
|
|
945
|
+
// ([data-msgfb], thumbs up/down) and user turns don't. The 2026-06
|
|
946
|
+
// chat DOM dropped the "Claude"/"You" text prefixes the old check
|
|
947
|
+
// keyed off (kept as a fallback for older builds). In this two-party
|
|
948
|
+
// chat a non-assistant turn is the human, so default to 'user'.
|
|
949
|
+
const isAssistant = !!d.querySelector('[data-msgfb]') || /^Claude(\\n|$)/.test(txt);
|
|
950
|
+
return { role: isAssistant ? 'assistant' : 'user', text: txt };
|
|
951
|
+
});
|
|
952
|
+
})()`)
|
|
953
|
+
.catch(() => [])) || []);
|
|
954
|
+
}
|
|
955
|
+
async ask(prompt, { file, timeoutMs = 5 * 60_000, stabilityMs = 2500, pollMs = 1000 } = {}) {
|
|
956
|
+
await this._ensureInSession();
|
|
957
|
+
if (file)
|
|
958
|
+
await this.openFile(file);
|
|
959
|
+
const beforeCount = (await this.getChatTurns()).length;
|
|
960
|
+
await this._submitPrompt(prompt);
|
|
961
|
+
(0, session_store_1.appendHistory)(this.key, { kind: 'ask', prompt });
|
|
962
|
+
const start = Date.now();
|
|
963
|
+
let lastText = '';
|
|
964
|
+
let stableSince = 0;
|
|
965
|
+
while (Date.now() - start < timeoutMs) {
|
|
966
|
+
const turns = await this.getChatTurns();
|
|
967
|
+
if (turns.length >= beforeCount + 2) {
|
|
968
|
+
const last = turns[turns.length - 1];
|
|
969
|
+
if (last && last.role === 'assistant') {
|
|
970
|
+
if (last.text === lastText && last.text.length > 0) {
|
|
971
|
+
if (!stableSince)
|
|
972
|
+
stableSince = Date.now();
|
|
973
|
+
if (Date.now() - stableSince > stabilityMs) {
|
|
974
|
+
const reply = last.text
|
|
975
|
+
.replace(/^Claude(?:\n+)?/, '')
|
|
976
|
+
.replace(/^(?:Searching|Reading|Thinking)\s*\n+/i, '')
|
|
977
|
+
.trim();
|
|
978
|
+
(0, session_store_1.appendHistory)(this.key, { kind: 'ask_reply', textBytes: reply.length });
|
|
979
|
+
return { ok: true, elapsedMs: Date.now() - start, reply, failureMode: null };
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
stableSince = 0;
|
|
984
|
+
lastText = last.text;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
989
|
+
}
|
|
990
|
+
return { ok: false, elapsedMs: Date.now() - start, reply: null, failureMode: 'timeout' };
|
|
991
|
+
}
|
|
992
|
+
async getIframeSrc() {
|
|
993
|
+
const src = await this.browser
|
|
994
|
+
.evalValue(`(() => { const el = document.querySelector(${JSON.stringify(this.selectors.preview.iframeOrContainer)}); return (el && el.src) || ''; })()`)
|
|
995
|
+
.catch(() => '');
|
|
996
|
+
return src || '';
|
|
997
|
+
}
|
|
998
|
+
// Node-side fetch of the preview src. The LEGACY signed-token regime
|
|
999
|
+
// (`claudeusercontent.com/...?t=<token>`) authorizes this fetch and returns
|
|
1000
|
+
// the file's real rendered HTML. The 2026-06 bootstrap regime does NOT (see
|
|
1001
|
+
// fetchServedHtml) — there the fetch returns the same ~1.1KB unauthenticated
|
|
1002
|
+
// loader shell for every file, so this is only the fallback floor.
|
|
1003
|
+
async _fetchServedHtmlNode(src) {
|
|
1004
|
+
try {
|
|
1005
|
+
const res = await fetch(src, { headers: { Accept: 'text/html' } });
|
|
1006
|
+
if (!res.ok)
|
|
1007
|
+
return { src, html: '' };
|
|
1008
|
+
return { src, html: await res.text() };
|
|
1009
|
+
}
|
|
1010
|
+
catch {
|
|
1011
|
+
return { src, html: '' };
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
// Reads the design preview's served HTML. The preview iframe addressing has
|
|
1015
|
+
// two regimes (preview-host.ts/previewIframeVariant):
|
|
1016
|
+
//
|
|
1017
|
+
// - signed-token (legacy): a node fetch of the `?t=<token>` URL is
|
|
1018
|
+
// authorized and returns the file's real rendered HTML — keep it.
|
|
1019
|
+
// - bootstrap-subdomain (2026-06, issue #61): the src is a filename-agnostic
|
|
1020
|
+
// `<uuid>.claudeusercontent.com/_bootstrap` with NO token. A node fetch
|
|
1021
|
+
// (no claude.ai cookies) returns the same ~1.1KB unauthenticated loader
|
|
1022
|
+
// shell for EVERY file — never the rendered HTML. The rendered DOM lives
|
|
1023
|
+
// only inside the cross-origin out-of-process iframe (OOPIF), which the
|
|
1024
|
+
// parent page JS can't read. So read it over CDP via OopifHtmlReader
|
|
1025
|
+
// (review #4, live-verified). Honors the DESIGNER_CDP='' opt-out and
|
|
1026
|
+
// degrades to the node fetch on any failure, so every existing caller
|
|
1027
|
+
// (snapshot, iterate's post-gen snapshot, the no_change signal, and the
|
|
1028
|
+
// _waitForGenerationDoneHtml fallback) behaves at least as before.
|
|
1029
|
+
//
|
|
1030
|
+
// CONTRACT: "served HTML" here is the preview's RENDERED DOM (outerHTML in the
|
|
1031
|
+
// bootstrap regime; the served response body in the signed-token regime), NOT
|
|
1032
|
+
// the on-disk source file. For the byte-stability / no_change / snapshot uses
|
|
1033
|
+
// that is exactly right (they care what the preview shows). A caller that needs
|
|
1034
|
+
// authoritative file SOURCE must use `handoff` (the Share/Export bundle), not
|
|
1035
|
+
// this. Returns html:'' (never the loader shell) when no real HTML is readable.
|
|
1036
|
+
async fetchServedHtml(sharedReader) {
|
|
1037
|
+
const src = await this.getIframeSrc();
|
|
1038
|
+
if (!src || !(0, preview_host_1.isPreviewIframeSrc)(src))
|
|
1039
|
+
return { src: '', html: '' };
|
|
1040
|
+
const variant = (0, preview_host_1.previewIframeVariant)(src);
|
|
1041
|
+
if (variant === 'bootstrap-subdomain') {
|
|
1042
|
+
// A node fetch of a bootstrap src returns ONLY the ~1.1KB loader shell,
|
|
1043
|
+
// never the file — so the OOPIF read is the only real source here. On any
|
|
1044
|
+
// failure (or the DESIGNER_CDP='' opt-out) return EMPTY, never the shell, so
|
|
1045
|
+
// callers (snapshot, the no_change signal, the byte-stability settle) treat
|
|
1046
|
+
// it as "no sample" instead of byte-comparing or saving a loader as the
|
|
1047
|
+
// captured artifact (#67 review).
|
|
1048
|
+
const cdpEnabled = (0, cdp_env_1.isCdpEnabled)();
|
|
1049
|
+
if (!cdpEnabled)
|
|
1050
|
+
return { src, html: '' };
|
|
1051
|
+
// A poll loop passes a shared reader (attached once via withPreviewReader)
|
|
1052
|
+
// to amortize the WS-open/connect cost across polls and avoid a connect
|
|
1053
|
+
// storm (#67 review perf); a live reader reuses in ~8ms/read. One-shot
|
|
1054
|
+
// callers pass nothing → attach-and-close a fresh reader here.
|
|
1055
|
+
if (sharedReader) {
|
|
1056
|
+
const html = await sharedReader.readPreviewHtml().catch(() => null);
|
|
1057
|
+
return { src, html: html || '' };
|
|
1058
|
+
}
|
|
1059
|
+
const reader = await this.attachPreviewReader();
|
|
1060
|
+
if (reader) {
|
|
1061
|
+
try {
|
|
1062
|
+
const html = await reader.readPreviewHtml().catch(() => null);
|
|
1063
|
+
if (html)
|
|
1064
|
+
return { src, html };
|
|
1065
|
+
}
|
|
1066
|
+
finally {
|
|
1067
|
+
reader.close();
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
return { src, html: '' };
|
|
1071
|
+
}
|
|
1072
|
+
// signed-token / 'other': the node fetch is authoritative (real rendered HTML).
|
|
1073
|
+
return this._fetchServedHtmlNode(src);
|
|
1074
|
+
}
|
|
1075
|
+
// Attach ONE OopifHtmlReader for the duration of a served-HTML poll loop and
|
|
1076
|
+
// reuse it per poll (each readPreviewHtml re-arms in ~8ms), instead of opening a
|
|
1077
|
+
// fresh CDP socket per poll — amortizes the WS-open/connect cost and avoids the
|
|
1078
|
+
// connect storm on long settle/fallback loops (#67 review perf). The reader is
|
|
1079
|
+
// best-effort: if attach fails or the regime isn't bootstrap, fetchServedHtml
|
|
1080
|
+
// falls back to its own per-call path. Closed in finally.
|
|
1081
|
+
async withPreviewReader(run) {
|
|
1082
|
+
const reader = (0, cdp_env_1.isCdpEnabled)() ? await this.attachPreviewReader() : null;
|
|
1083
|
+
try {
|
|
1084
|
+
return await run(() => this.fetchServedHtml(reader));
|
|
1085
|
+
}
|
|
1086
|
+
finally {
|
|
1087
|
+
reader?.close();
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
// Attach an OopifHtmlReader bound to the current tab. The FULL current URL
|
|
1091
|
+
// (with ?file=) is passed so findDesignTarget exact-matches the agent-browser-
|
|
1092
|
+
// driven tab — two same-project tabs on different files must not cross-bind
|
|
1093
|
+
// (#67 review). Degrades to null on any failure (caller falls back to the node
|
|
1094
|
+
// fetch / treats it as no sample).
|
|
1095
|
+
async attachPreviewReader() {
|
|
1096
|
+
return oopif_reader_1.OopifHtmlReader.attach({ preferUrlPrefix: (await this.currentUrl()) || null }).catch(() => null);
|
|
1097
|
+
}
|
|
1098
|
+
// Fetch the project's export zip via the authenticated, same-origin endpoint
|
|
1099
|
+
// the Share→Export "Download" button hits — `/design/v1/design/projects/<id>
|
|
1100
|
+
// /download` (returns application/zip). The 2026-06-21 redesign removed the old
|
|
1101
|
+
// public `api.anthropic.com/v1/design/h/<id>` tar.gz URL and replaced it with a
|
|
1102
|
+
// browser download that needs a trusted gesture CDP can't fire — but the bytes
|
|
1103
|
+
// come from a plain GET. We do it IN-PAGE (auth + Cloudflare just work there; a
|
|
1104
|
+
// node-side fetch with copied cookies 403s) and transfer the bytes out as
|
|
1105
|
+
// base64. Throws on non-200 / non-zip so the caller surfaces a clear failure.
|
|
1106
|
+
async _downloadProjectZip(projectId) {
|
|
1107
|
+
// Origin guard: the in-page fetch is same-origin, so if the bound tab has
|
|
1108
|
+
// drifted off claude.ai (tab drift is real — it bit this very session) the
|
|
1109
|
+
// '/design/v1/...' path would resolve against the wrong app and 404. Refuse
|
|
1110
|
+
// rather than return junk.
|
|
1111
|
+
const url = await this.currentUrl();
|
|
1112
|
+
if (!/^https:\/\/claude\.ai\/design\//.test(url)) {
|
|
1113
|
+
throw new Error(`Active tab is not on claude.ai/design (${url || 'unknown'}) — refusing to fetch the export from the wrong origin.`);
|
|
1114
|
+
}
|
|
1115
|
+
// In-page authed GET with an abort deadline; returns {status, bytes, b64} or
|
|
1116
|
+
// {status, err}. Bytes are carried out as chunked base64 (byte-exact); the
|
|
1117
|
+
// server byte count comes back too so we can detect a truncated transfer.
|
|
1118
|
+
const expr = (timeoutMs) => `(async () => {
|
|
1119
|
+
const ctrl = new AbortController();
|
|
1120
|
+
const to = setTimeout(() => ctrl.abort(), ${timeoutMs});
|
|
1121
|
+
try {
|
|
1122
|
+
const r = await fetch('/design/v1/design/projects/' + ${JSON.stringify(projectId)} + '/download', { headers: { Accept: '*/*' }, signal: ctrl.signal });
|
|
1123
|
+
if (!r.ok) return { status: r.status };
|
|
1124
|
+
const bytes = new Uint8Array(await r.arrayBuffer());
|
|
1125
|
+
let bin = '';
|
|
1126
|
+
const CH = 0x8000;
|
|
1127
|
+
for (let i = 0; i < bytes.length; i += CH) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CH));
|
|
1128
|
+
return { status: 200, bytes: bytes.length, b64: btoa(bin) };
|
|
1129
|
+
} catch (e) { return { status: 0, err: String((e && e.message) || e) }; }
|
|
1130
|
+
finally { clearTimeout(to); }
|
|
1131
|
+
})()`;
|
|
1132
|
+
// Bounded retry: fail fast on auth/not-found, retry transient (abort/0/429/5xx).
|
|
1133
|
+
let lastErr = 'unknown';
|
|
1134
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
1135
|
+
const res = await this.browser
|
|
1136
|
+
.evalValue(expr(25_000))
|
|
1137
|
+
.catch((e) => ({ status: 0, err: String(e?.message || e) }));
|
|
1138
|
+
if (res.status === 200 && typeof res.b64 === 'string') {
|
|
1139
|
+
const buf = Buffer.from(res.b64, 'base64');
|
|
1140
|
+
if (typeof res.bytes === 'number' && buf.length !== res.bytes) {
|
|
1141
|
+
lastErr = `truncated transfer (${buf.length} of ${res.bytes} bytes)`; // retry
|
|
1142
|
+
}
|
|
1143
|
+
else if (buf.length < 100 || buf.subarray(0, 2).toString('latin1') !== 'PK') {
|
|
1144
|
+
throw new Error(`Project download is not a zip (${buf.length} bytes).`);
|
|
1145
|
+
}
|
|
1146
|
+
else {
|
|
1147
|
+
return buf;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
else {
|
|
1151
|
+
lastErr = res.err ? res.err : `HTTP ${res.status}`;
|
|
1152
|
+
if (res.status === 401 || res.status === 403 || res.status === 404) {
|
|
1153
|
+
throw new Error(`Project download failed (HTTP ${res.status}). Are you signed in to claude.ai/design?`);
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
if (attempt < 2)
|
|
1157
|
+
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)));
|
|
1158
|
+
}
|
|
1159
|
+
throw new Error(`Project download failed after 3 attempts: ${lastErr}.`);
|
|
1160
|
+
}
|
|
1161
|
+
// Capture the FULL chat transcript despite virtualization (claude.ai mounts only
|
|
1162
|
+
// the visible rows). Scroll the chat history top→bottom, accumulating turns keyed
|
|
1163
|
+
// by data-index so rows that unmount as we scroll are still kept. Best-effort: if
|
|
1164
|
+
// the scroll container isn't found, degrades to the visible window.
|
|
1165
|
+
async _collectChatTurns() {
|
|
1166
|
+
const collected = new Map();
|
|
1167
|
+
const scrape = async () => {
|
|
1168
|
+
const rows = await this.browser
|
|
1169
|
+
.evalValue(`(() => {
|
|
1170
|
+
const c = document.querySelector('[data-testid="chat-messages"]');
|
|
1171
|
+
const inner = c && c.children[0];
|
|
1172
|
+
if (!inner) return [];
|
|
1173
|
+
return Array.from(inner.children).map((d) => {
|
|
1174
|
+
const idx = parseInt(d.getAttribute('data-index') || '-1', 10);
|
|
1175
|
+
const txt = (d.innerText || '').trim();
|
|
1176
|
+
const isAssistant = !!d.querySelector('[data-msgfb]') || /^Claude(\\n|$)/.test(txt);
|
|
1177
|
+
return { idx, role: isAssistant ? 'assistant' : 'user', text: txt };
|
|
1178
|
+
});
|
|
1179
|
+
})()`)
|
|
1180
|
+
.catch(() => []);
|
|
1181
|
+
for (const r of rows)
|
|
1182
|
+
if (r.idx >= 0 && r.text)
|
|
1183
|
+
collected.set(r.idx, { role: r.role, text: r.text });
|
|
1184
|
+
};
|
|
1185
|
+
const scroll = (dir) => this.browser
|
|
1186
|
+
.evalValue(`(() => {
|
|
1187
|
+
let s = document.querySelector('[data-testid="chat-messages"]');
|
|
1188
|
+
for (let i = 0; i < 8 && s; i++) { if (s.scrollHeight > s.clientHeight + 4) break; s = s.parentElement; }
|
|
1189
|
+
if (!s) return -1;
|
|
1190
|
+
if (${JSON.stringify(dir)} === 'top') s.scrollTop = 0; else s.scrollTop = Math.min(s.scrollTop + s.clientHeight, s.scrollHeight);
|
|
1191
|
+
return s.scrollTop;
|
|
1192
|
+
})()`)
|
|
1193
|
+
.catch(() => -1);
|
|
1194
|
+
await scroll('top');
|
|
1195
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
1196
|
+
let lastTop = -2;
|
|
1197
|
+
let stable = 0;
|
|
1198
|
+
for (let i = 0; i < 40; i++) {
|
|
1199
|
+
await scrape();
|
|
1200
|
+
const top = await scroll('down');
|
|
1201
|
+
if (top < 0)
|
|
1202
|
+
break; // no scroller — single visible-window pass
|
|
1203
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
1204
|
+
if (top === lastTop) {
|
|
1205
|
+
if (++stable >= 2)
|
|
1206
|
+
break;
|
|
1207
|
+
}
|
|
1208
|
+
else {
|
|
1209
|
+
stable = 0;
|
|
1210
|
+
lastTop = top;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
await scrape();
|
|
1214
|
+
return [...collected.entries()].sort((a, b) => a[0] - b[0]).map(([, t]) => t);
|
|
1215
|
+
}
|
|
1216
|
+
async handoff({ openFile } = {}) {
|
|
1217
|
+
await this._ensureInSession();
|
|
1218
|
+
if (openFile)
|
|
1219
|
+
await this.openFile(openFile);
|
|
1220
|
+
const baseUrl = (0, session_store_1.getSession)(this.key)?.designUrl || (await this.currentUrl());
|
|
1221
|
+
const m = baseUrl.match(exports.SESSION_URL_RE);
|
|
1222
|
+
if (!m || !m[1])
|
|
1223
|
+
throw new Error(`No /design/p/<uuid> project bound to key=${this.key} to hand off.`);
|
|
1224
|
+
const projectId = m[1];
|
|
1225
|
+
const projectUrl = baseUrl.split('?')[0] ?? baseUrl;
|
|
1226
|
+
// Cross-project guard: the in-page chat scrape AND the export fetch run on the
|
|
1227
|
+
// ACTIVE tab. _ensureInSession returns early on any /design/p/ tab and tab
|
|
1228
|
+
// drift is real, so the active tab can be a DIFFERENT project than the bound
|
|
1229
|
+
// one — which would pair project B's chat with project A's files. Pin the tab
|
|
1230
|
+
// to the bound project first so both come from one project.
|
|
1231
|
+
const curId = (await this.currentUrl()).match(exports.SESSION_URL_RE)?.[1];
|
|
1232
|
+
if (curId !== projectId)
|
|
1233
|
+
await this.resumeSession();
|
|
1234
|
+
// The export zip dropped the README + chat transcript the old tar.gz carried,
|
|
1235
|
+
// so regenerate the decision record from the live chat (virtualization-aware).
|
|
1236
|
+
const turns = await this._collectChatTurns().catch(() => []);
|
|
1237
|
+
const decisionRecord = renderDecisionRecord(turns, projectId, projectUrl);
|
|
1238
|
+
const zip = await this._downloadProjectZip(projectId);
|
|
1239
|
+
// Build the bundle atomically: assemble in a temp dir, rename into place only
|
|
1240
|
+
// on full success, so a crash/partial extract never leaves a half-built bundle
|
|
1241
|
+
// that `tasting` would pick up as the latest complete handoff.
|
|
1242
|
+
const dir = (0, artifact_store_1.sessionDir)(this.key);
|
|
1243
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1244
|
+
const bundleDir = node_path_1.default.join(dir, `handoff-${stamp}`);
|
|
1245
|
+
const tmpDir = node_path_1.default.join(dir, `.handoff-${stamp}.tmp`);
|
|
1246
|
+
const tmpProject = node_path_1.default.join(tmpDir, 'project');
|
|
1247
|
+
node_fs_1.default.mkdirSync(tmpProject, { recursive: true });
|
|
1248
|
+
try {
|
|
1249
|
+
// Extract in-process (no external `unzip` — absent on Windows / minimal CI).
|
|
1250
|
+
const entries = (0, fflate_1.unzipSync)(new Uint8Array(zip));
|
|
1251
|
+
let extracted = 0;
|
|
1252
|
+
for (const [name, data] of Object.entries(entries)) {
|
|
1253
|
+
if (!name || name.endsWith('/'))
|
|
1254
|
+
continue;
|
|
1255
|
+
const dest = node_path_1.default.join(tmpProject, name);
|
|
1256
|
+
if (dest !== tmpProject && !dest.startsWith(tmpProject + node_path_1.default.sep))
|
|
1257
|
+
continue; // zip-slip guard
|
|
1258
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(dest), { recursive: true });
|
|
1259
|
+
node_fs_1.default.writeFileSync(dest, Buffer.from(data));
|
|
1260
|
+
extracted++;
|
|
1261
|
+
}
|
|
1262
|
+
if (extracted === 0)
|
|
1263
|
+
throw new Error('export zip contained no files');
|
|
1264
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(tmpDir, 'bundle.zip'), zip);
|
|
1265
|
+
node_fs_1.default.writeFileSync(node_path_1.default.join(tmpDir, 'decision-record.md'), decisionRecord);
|
|
1266
|
+
const repaired = repairEmDashLinks(tmpProject);
|
|
1267
|
+
node_fs_1.default.renameSync(tmpDir, bundleDir); // commit
|
|
1268
|
+
const projectDir = node_path_1.default.join(bundleDir, 'project');
|
|
1269
|
+
// Design inventory = project/ only (the zip + record aren't design files).
|
|
1270
|
+
const files = listAllFiles(projectDir).map((p) => node_path_1.default.relative(bundleDir, p));
|
|
1271
|
+
(0, session_store_1.appendHistory)(this.key, { kind: 'handoff', projectId, bundleDir, fileCount: files.length, turns: turns.length, repaired });
|
|
1272
|
+
return {
|
|
1273
|
+
ok: true,
|
|
1274
|
+
projectId,
|
|
1275
|
+
projectUrl,
|
|
1276
|
+
bundleDir,
|
|
1277
|
+
slugDir: bundleDir,
|
|
1278
|
+
projectDir,
|
|
1279
|
+
decisionRecordPath: node_path_1.default.join(bundleDir, 'decision-record.md'),
|
|
1280
|
+
decisionRecordBytes: Buffer.byteLength(decisionRecord),
|
|
1281
|
+
decisionRecordTurns: turns.length,
|
|
1282
|
+
decisionRecordEmpty: turns.length === 0,
|
|
1283
|
+
zipPath: node_path_1.default.join(bundleDir, 'bundle.zip'),
|
|
1284
|
+
zipBytes: zip.length,
|
|
1285
|
+
files,
|
|
1286
|
+
repaired
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
catch (e) {
|
|
1290
|
+
node_fs_1.default.rmSync(tmpDir, { recursive: true, force: true });
|
|
1291
|
+
throw e;
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
async _clickButtonByText(pattern) {
|
|
1295
|
+
const re = pattern instanceof RegExp ? pattern : new RegExp(pattern);
|
|
1296
|
+
return this.browser.evalValue(`(() => {
|
|
1297
|
+
const re = new RegExp(${JSON.stringify(re.source)}, ${JSON.stringify(re.flags)});
|
|
1298
|
+
const btn = Array.from(document.querySelectorAll('button')).find(b => re.test((b.textContent || '').trim()));
|
|
1299
|
+
if (!btn) throw new Error('button not found: ' + ${JSON.stringify(re.source)});
|
|
1300
|
+
btn.click();
|
|
1301
|
+
return true;
|
|
1302
|
+
})()`);
|
|
1303
|
+
}
|
|
1304
|
+
async close() {
|
|
1305
|
+
await this.browser.close().catch(() => null);
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
exports.DesignerController = DesignerController;
|
|
1309
|
+
function hashHex(s) {
|
|
1310
|
+
return node_crypto_1.default.createHash('sha256').update(s).digest('hex').slice(0, 16);
|
|
1311
|
+
}
|
|
1312
|
+
function escapeRegExp(s) {
|
|
1313
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
1314
|
+
}
|
|
1315
|
+
// The export zip no longer ships the README + chat transcript the old tar.gz
|
|
1316
|
+
// did, so reconstruct a decision record from the live chat turns (every prompt +
|
|
1317
|
+
// reply, verbatim) — the "why" a coding agent needs alongside the files.
|
|
1318
|
+
function renderDecisionRecord(turns, projectId, projectUrl) {
|
|
1319
|
+
// getChatTurns role-detects off the turn text starting with "Claude"/"You";
|
|
1320
|
+
// the current chat DOM dropped those text labels (turns carry only data-index),
|
|
1321
|
+
// so roles can come back all-'unknown'. Don't mislabel them "Note" — fall back
|
|
1322
|
+
// to sequential "Turn N" and flag the gap. (Role attribution is a separate
|
|
1323
|
+
// getChatTurns drift, tracked independently.)
|
|
1324
|
+
const attributed = turns.some((t) => t.role !== 'unknown');
|
|
1325
|
+
const out = [
|
|
1326
|
+
'# Design handoff — decision record',
|
|
1327
|
+
'',
|
|
1328
|
+
`Project: ${projectUrl}`,
|
|
1329
|
+
`Project ID: ${projectId}`,
|
|
1330
|
+
`Captured: ${new Date().toISOString()}`,
|
|
1331
|
+
''
|
|
1332
|
+
];
|
|
1333
|
+
if (!turns.length) {
|
|
1334
|
+
out.push('## Conversation', '', '_(no chat turns captured)_');
|
|
1335
|
+
return out.join('\n');
|
|
1336
|
+
}
|
|
1337
|
+
out.push('## Conversation (verbatim — the decisions behind the design)');
|
|
1338
|
+
if (!attributed) {
|
|
1339
|
+
out.push('', '_Speaker labels unavailable (claude.ai dropped role markers from the chat DOM); turns are shown in order._');
|
|
1340
|
+
}
|
|
1341
|
+
out.push('');
|
|
1342
|
+
turns.forEach((t, i) => {
|
|
1343
|
+
const role = t.role === 'assistant' ? 'Claude' : t.role === 'user' ? 'You' : `Turn ${i + 1}`;
|
|
1344
|
+
out.push(`### ${role}`, '', t.text.trim(), '');
|
|
1345
|
+
});
|
|
1346
|
+
return out.join('\n');
|
|
1347
|
+
}
|
|
1348
|
+
function extractFileParam(url) {
|
|
1349
|
+
try {
|
|
1350
|
+
return new URL(url).searchParams.get('file');
|
|
1351
|
+
}
|
|
1352
|
+
catch {
|
|
1353
|
+
return null;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
// Claude's handoff pipeline (as of 2026-04) writes em-dashes (—, U+2014) into
|
|
1357
|
+
// the index.html hrefs but saves on-disk filenames with regular hyphens (-).
|
|
1358
|
+
// We detect this mismatch and rename files to match the hrefs. Safe if fixed
|
|
1359
|
+
// upstream: if the href already resolves, we leave everything alone.
|
|
1360
|
+
function repairEmDashLinks(projectDir) {
|
|
1361
|
+
const report = { renamed: [], skipped: [] };
|
|
1362
|
+
if (!node_fs_1.default.existsSync(projectDir))
|
|
1363
|
+
return report;
|
|
1364
|
+
const indexPath = node_path_1.default.join(projectDir, 'index.html');
|
|
1365
|
+
if (!node_fs_1.default.existsSync(indexPath))
|
|
1366
|
+
return report;
|
|
1367
|
+
const indexHtml = node_fs_1.default.readFileSync(indexPath, 'utf8');
|
|
1368
|
+
const hrefs = new Set();
|
|
1369
|
+
for (const m of indexHtml.matchAll(/href="([^"#?]+\.html)"/g)) {
|
|
1370
|
+
const raw = m[1];
|
|
1371
|
+
if (!raw)
|
|
1372
|
+
continue;
|
|
1373
|
+
try {
|
|
1374
|
+
hrefs.add(decodeURIComponent(raw));
|
|
1375
|
+
}
|
|
1376
|
+
catch {
|
|
1377
|
+
hrefs.add(raw);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
for (const wanted of hrefs) {
|
|
1381
|
+
const wantedPath = node_path_1.default.join(projectDir, wanted);
|
|
1382
|
+
if (node_fs_1.default.existsSync(wantedPath))
|
|
1383
|
+
continue;
|
|
1384
|
+
const candidate = wanted.replace(/\u2014/g, '-').replace(/\s-\s/g, ' - ');
|
|
1385
|
+
const candidatePath = node_path_1.default.join(projectDir, candidate);
|
|
1386
|
+
if (candidate !== wanted && node_fs_1.default.existsSync(candidatePath)) {
|
|
1387
|
+
node_fs_1.default.renameSync(candidatePath, wantedPath);
|
|
1388
|
+
report.renamed.push({ from: candidate, to: wanted });
|
|
1389
|
+
}
|
|
1390
|
+
else {
|
|
1391
|
+
report.skipped.push(wanted);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
return report;
|
|
1395
|
+
}
|
|
1396
|
+
function listAllFiles(root) {
|
|
1397
|
+
const out = [];
|
|
1398
|
+
const stack = [root];
|
|
1399
|
+
while (stack.length) {
|
|
1400
|
+
const cur = stack.pop();
|
|
1401
|
+
if (!cur)
|
|
1402
|
+
continue;
|
|
1403
|
+
for (const entry of node_fs_1.default.readdirSync(cur)) {
|
|
1404
|
+
const p = node_path_1.default.join(cur, entry);
|
|
1405
|
+
const st = node_fs_1.default.statSync(p);
|
|
1406
|
+
if (st.isDirectory())
|
|
1407
|
+
stack.push(p);
|
|
1408
|
+
else
|
|
1409
|
+
out.push(p);
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
return out;
|
|
1413
|
+
}
|