diff --git a/.all-contributorsrc b/.all-contributorsrc index bf2e668c..53f76239 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -3569,6 +3569,15 @@ "contributions": [ "content" ] + }, + { + "login": "thesurenk", + "name": "Suren K", + "avatar_url": "https://avatars.githubusercontent.com/u/902972?v=4", + "profile": "https://surenk.com", + "contributions": [ + "doc" + ] } ] } diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index db18b699..15f6192b 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -160,6 +160,12 @@ "description": "Quickly swipe through backlog issues to triage decisions like assign, needs-info, defer, close, or ignore.", "version": "1.0.2" }, + { + "name": "backrooms-canvas", + "source": "extensions/backrooms-canvas", + "description": "Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls.", + "version": "1.0.0" + }, { "name": "cast-imaging", "source": "plugins/cast-imaging", @@ -244,8 +250,8 @@ { "name": "connector-namespaces", "source": "extensions/connector-namespaces", - "description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.", - "version": "1.1.2" + "description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.", + "version": "1.2.0" }, { "name": "context-engineering", @@ -550,33 +556,6 @@ "description": "Give your AI agent full visibility into Power Automate cloud flows via the FlowStudio MCP server. Connect, debug, build, monitor health, and govern flows at scale — action-level inputs and outputs, not just status codes.", "version": "2.0.0" }, - { - "name": "foundry-agent-canvas", - "description": "Interactive Copilot canvas for designing, configuring, testing, and deploying Microsoft Foundry hosted agents.", - "version": "1.0.1", - "author": { - "name": "Microsoft", - "url": "https://www.microsoft.com" - }, - "repository": "https://github.com/microsoft/foundry-toolkit", - "homepage": "https://github.com/microsoft/foundry-toolkit", - "license": "MIT", - "keywords": [ - "agent-builder", - "agent-inspector", - "azure-ai", - "foundry", - "hosted-agents", - "microsoft-foundry", - "canvas" - ], - "source": { - "source": "github", - "repo": "microsoft/foundry-toolkit", - "path": "foundry-agent-canvas", - "sha": "e16be2b8533ca82c22806388e07581e7497785d7" - } - }, { "name": "frontend-web-dev", "source": "plugins/frontend-web-dev", @@ -587,7 +566,7 @@ "name": "gem-team", "source": "plugins/gem-team", "description": "Self-Learning Multi-agent orchestration framework for spec-driven development and automated verification. With smarter tool calling and leaner context.", - "version": "1.84.0" + "version": "1.87.0" }, { "name": "gesture-review", @@ -652,7 +631,7 @@ { "name": "github-copilot-modernization", "description": "Autonomous application modernization using multi-agent orchestration for GitHub Copilot CLI. Supports Java upgrades (8→21, Spring Boot 2.x→3.x), .NET modernization, Azure migration, CVE/vulnerability fixing, and application rearchitecture (monolith-to-microservices). Features a 3-level agent hierarchy (orchestrator → coordinators → executors) with enterprise rulebook support for embedding organizational policies into the workflow.", - "version": "1.20.0", + "version": "1.22.0", "author": { "name": "Microsoft", "url": "https://github.com/microsoft/github-copilot-modernization" @@ -676,7 +655,7 @@ "source": "github", "repo": "microsoft/github-copilot-modernization", "path": "plugins/github-copilot-modernization", - "sha": "42c1189c55933384bec07e8349ef998eb9e775ad" + "sha": "8b644bebc7e1f929c01d80788293a37872f480f8" } }, { @@ -767,6 +746,33 @@ "repo": "microsoft/Build-CLI" } }, + { + "name": "microsoft-foundry", + "description": "Skills and interactive Copilot canvas for designing, configuring, testing and deploying agents to Microsoft Foundry.", + "version": "1.0.3", + "author": { + "name": "Microsoft", + "url": "https://www.microsoft.com" + }, + "repository": "https://github.com/microsoft/foundry-toolkit", + "homepage": "https://github.com/microsoft/foundry-toolkit", + "license": "MIT", + "keywords": [ + "agent-builder", + "agent-inspector", + "azure-ai", + "foundry", + "hosted-agents", + "microsoft-foundry", + "canvas" + ], + "source": { + "source": "github", + "repo": "microsoft/foundry-toolkit", + "path": "microsoft-foundry", + "sha": "9e5fae9942514a8b90281886285052a99fc1932b" + } + }, { "name": "modernize-dotnet", "description": "AI-powered .NET modernization and upgrade assistant. Helps upgrade .NET Framework and .NET applications to the latest versions of .NET.", @@ -794,7 +800,7 @@ { "name": "modernize-java", "description": "GitHub Copilot modernization – Java Upgrade CLI Plugin helps you upgrade Java applications from the command line. It brings intelligent modernization capabilities to your terminal and CI/CD pipelines: analyze your project and generate an upgrade plan, automatically transform your codebase, fix build issues, validate against known CVEs, and output a detailed summary of file changes and updated dependencies.", - "version": "1.9.2", + "version": "1.22.0", "author": { "name": "microsoft", "url": "https://github.com/microsoft/modernize-java" @@ -812,8 +818,8 @@ "source": "github", "repo": "microsoft/modernize-java", "path": "plugins/modernize-java", - "ref": "1.9.2", - "sha": "b570196c070bf1eb9d7ad34a263b228ef16034a0" + "ref": "1.22.0", + "sha": "ef5367b446566bdc90960deeb40def63f9e7024e" } }, { @@ -1024,6 +1030,12 @@ "description": "Security frameworks, accessibility guidelines, performance optimization, and code quality best practices for building secure, maintainable, and high-performance applications.", "version": "1.0.0" }, + { + "name": "signals-dashboard", + "source": "extensions/signals-dashboard", + "description": "Real-time agent coordination dashboard for The Workshop. Shows desk status, signal types (done, checkpoint, blocked, hands-up, partnership), intent text, outcome pairing with honesty gap, token usage, and stash/restore controls.", + "version": "0.1.0" + }, { "name": "site-studio", "source": "extensions/site-studio", @@ -1114,6 +1126,12 @@ "description": "Comprehensive collection for writing tests, test automation, and test-driven development including unit tests, integration tests, and end-to-end testing strategies.", "version": "1.0.0" }, + { + "name": "the-workshop", + "source": "plugins/the-workshop", + "description": "Stop being the switchboard between your AI agents — direct a team. The Workshop puts long-running AI agents (desks) in the same room, on the same work, each with its own memory and history, sharing one workspace so you direct the work instead of relaying it.", + "version": "0.1.0" + }, { "name": "tiny-tool-town-submitter", "source": "extensions/tiny-tool-town-submitter", @@ -1169,7 +1187,7 @@ { "name": "ui5", "description": "SAPUI5 / OpenUI5 plugin for GitHub CoPilot. Create and validate UI5 projects, access API documentation, run UI5 linter, get development guidelines and best practices for UI5 development.", - "version": "0.1.4", + "version": "0.1.7", "author": { "name": "SAP SE", "url": "https://www.sap.com" @@ -1190,13 +1208,13 @@ "source": "github", "repo": "UI5/plugins-coding-agents", "path": "plugins/ui5", - "sha": "80f2d93287054f9d30dd990e842e15bcfca581c9" + "ref": "v0.1.7" } }, { "name": "ui5-modernization", "description": "Complete UI5 modernization toolkit with workflow and specialized fix patterns for modernizing SAPUI5/OpenUI5 applications", - "version": "0.1.6", + "version": "0.1.7", "author": { "name": "SAP SE", "url": "https://www.sap.com" @@ -1216,13 +1234,13 @@ "source": "github", "repo": "UI5/plugins-coding-agents", "path": "plugins/ui5-modernization", - "ref": "v0.1.6" + "ref": "v0.1.7" } }, { "name": "ui5-typescript-conversion", "description": "SAPUI5 / OpenUI5 plugin for GitHub CoPilot. Convert JavaScript based UI5 projects to TypeScript.", - "version": "0.1.4", + "version": "0.1.7", "author": { "name": "SAP SE", "url": "https://www.sap.com" @@ -1244,19 +1262,24 @@ "source": "github", "repo": "UI5/plugins-coding-agents", "path": "plugins/ui5-typescript-conversion", - "sha": "80f2d93287054f9d30dd990e842e15bcfca581c9" + "ref": "v0.1.7" } }, + { + "name": "uizze", + "source": "plugins/uizze", + "description": "Stop generic UI from shipping. Ground GitHub Copilot in 800,000+ real web and iOS screens, write a product-specific design contract, and enforce a hard finish gate.", + "version": "1.0.0" + }, { "name": "upgrade-agent", - "description": "GitHub Copilot upgrade is an AI-powered agent that helps you upgrade applications to newer versions of languages, frameworks, and runtimes. It assesses your application, creates an upgrade plan, applies code changes, and validates the results through an interactive upgrade workflow.", - "version": "1.1.222", + "description": "AI-powered upgrade assistant for upgrading and migrating applications. Helps modernize legacy code and upgrade .NET applications to current frameworks.", + "version": "1.1.247", "author": { "name": "Microsoft", "url": "https://www.microsoft.com" }, - "repository": "https://github.com/microsoft/upgrade-agent-plugins", - "license": "MIT", + "homepage": "https://github.com/microsoft/upgrade-agent-plugins", "keywords": [ "modernization", "upgrade", @@ -1264,11 +1287,13 @@ "dotnet", "canvas" ], + "license": "MIT", + "repository": "https://github.com/microsoft/upgrade-agent-plugins", "source": { "source": "github", "repo": "microsoft/upgrade-agent-plugins", "path": "plugins/upgrade-agent", - "sha": "379d344e42823b25223f878c002f38fb3a2c1d2b" + "sha": "a70e1ae1ff63dd19ff874e874b4b587a5291f68a" } }, { diff --git a/README.md b/README.md index c3c5e4c1..9c735c9f 100644 --- a/README.md +++ b/README.md @@ -535,6 +535,7 @@ Thanks goes to these wonderful people ([emoji key](./CONTRIBUTING.md#contributor
Lovy Jain

kimtth

Akash Dwivedi
+
Suren K
diff --git a/agents/gem-browser-tester.agent.md b/agents/gem-browser-tester.agent.md index 5b860d18..6951a881 100644 --- a/agents/gem-browser-tester.agent.md +++ b/agents/gem-browser-tester.agent.md @@ -104,7 +104,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -114,6 +114,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-code-simplifier.agent.md b/agents/gem-code-simplifier.agent.md index d44330d9..a1318d80 100644 --- a/agents/gem-code-simplifier.agent.md +++ b/agents/gem-code-simplifier.agent.md @@ -70,6 +70,7 @@ Code Smells: long param list, feature envy, primitive obsession, magic numbers, Principles: preserve behavior, small steps, version control, one thing at a time. Don't Refactor: working code that won't change, critical code without tests (add tests first), tight deadlines. Ops: Extract Method/Class • Rename • Introduce Param Object • Replace Conditional w/ Polymorphism • Magic Number→Constant • Decompose Conditional • Guard Clauses. +Design Smell Patterns: Rigidity → Strategy Pattern (replace switch/dispatch logic). Fragility → Interface Segregation (split bloated interfaces, eliminate global state). Immobility → Layer separation (extract pure functions from UI/DB). Viscosity → Reduce boilerplate (make clean path = easy path). Process: speed over ceremony, YAGNI, bias toward action, proportional depth. @@ -109,7 +110,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -119,6 +120,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-critic.agent.md b/agents/gem-critic.agent.md index 765f1797..2ea17468 100644 --- a/agents/gem-critic.agent.md +++ b/agents/gem-critic.agent.md @@ -53,6 +53,10 @@ IMPORTANT: Batch/join dependency-free steps; serialize only true dependencies wh - Simplicity: Less code / files / patterns, simplest approach? - Conventions: Right reasons? - Coupling: Too tight or too loose? + - Rigidity: Would this design make future changes cascade? Are modules too coupled? + - Fragility: Could changes here break unrelated functionality? Hidden dependencies? + - Immobility: Can business logic be extracted without carrying framework/UI/DB baggage? + - Viscosity: Is doing it right significantly harder than a shortcut? If so, simplify the clean path. - Future-proofing: For a future that may not come? - Synthesize: - Findings grouped by severity: blocking, warning, or suggestion. @@ -100,7 +104,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -110,6 +114,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-debugger.agent.md b/agents/gem-debugger.agent.md index 66126a01..8f3765e4 100644 --- a/agents/gem-debugger.agent.md +++ b/agents/gem-debugger.agent.md @@ -112,7 +112,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -122,6 +122,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-designer-mobile.agent.md b/agents/gem-designer-mobile.agent.md index a96811b5..32e29bd6 100644 --- a/agents/gem-designer-mobile.agent.md +++ b/agents/gem-designer-mobile.agent.md @@ -192,7 +192,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -202,6 +202,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-designer.agent.md b/agents/gem-designer.agent.md index d17b5e5a..5eedfce4 100644 --- a/agents/gem-designer.agent.md +++ b/agents/gem-designer.agent.md @@ -154,7 +154,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -164,6 +164,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-devops.agent.md b/agents/gem-devops.agent.md index c22bb791..abccb8f1 100644 --- a/agents/gem-devops.agent.md +++ b/agents/gem-devops.agent.md @@ -157,7 +157,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -167,6 +167,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-documentation-writer.agent.md b/agents/gem-documentation-writer.agent.md index 1e721d9e..1c4924a9 100644 --- a/agents/gem-documentation-writer.agent.md +++ b/agents/gem-documentation-writer.agent.md @@ -137,7 +137,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -147,6 +147,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-implementer-mobile.agent.md b/agents/gem-implementer-mobile.agent.md index 57bf8e5d..cd8516e9 100644 --- a/agents/gem-implementer-mobile.agent.md +++ b/agents/gem-implementer-mobile.agent.md @@ -95,7 +95,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -105,6 +105,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-implementer.agent.md b/agents/gem-implementer.agent.md index 7660aa22..1d9109b3 100644 --- a/agents/gem-implementer.agent.md +++ b/agents/gem-implementer.agent.md @@ -94,7 +94,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -104,6 +104,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-mobile-tester.agent.md b/agents/gem-mobile-tester.agent.md index 89e0ed88..72b94839 100644 --- a/agents/gem-mobile-tester.agent.md +++ b/agents/gem-mobile-tester.agent.md @@ -115,7 +115,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -125,6 +125,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-orchestrator.agent.md b/agents/gem-orchestrator.agent.md index c1338fd9..fef1870a 100644 --- a/agents/gem-orchestrator.agent.md +++ b/agents/gem-orchestrator.agent.md @@ -21,9 +21,7 @@ IMPORTANT: You MUST STRICTLY perform `orchestration_work` only. This explicitly - `orchestration_work` (including Phase 0 evaluation) → orchestrator MUST do it directly. - `project_work` (Phases 1 through 4 task execution) → delegate to agent. -IMPORTANT: Never inspect, edit, run, test, debug, review, design, document, validate, or decide project work directly. `Phase 0` is your non-delegable entry point for every single interaction. - -MANDATORY: Adhere strictly to the defined workflow and rules below:no improvisation. +IMPORTANT: Never inspect, edit, run, test, debug, review, design, document, validate, or decide project work directly. `Phase 0` is your non-delegable entry point for every single interaction. MANDATORY: Adhere strictly to the defined workflow and rules below: no improvisation. @@ -423,7 +421,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -432,6 +430,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Execute autonomously: ask only for true blockers. Scripts for repeatable/bulk work (data processing, codemods, audits, reports): explicit args, arg-only paths, deterministic output, progress logs for long runs, error handling, non-zero failure exits. Test on small input first. Retry transient failures 3×. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-planner.agent.md b/agents/gem-planner.agent.md index ec5d3dba..a2ba95b5 100644 --- a/agents/gem-planner.agent.md +++ b/agents/gem-planner.agent.md @@ -79,6 +79,11 @@ IMPORTANT: Focus strictly on architectural milestones, dependency mapping, and s - Explicitly check for hidden assumptions, missing pre-requisites, potential edge cases, or gaps in the requirements. - If gaps or ambiguities are found that block a reliable plan, flag them immediately in `open_questions` (as `decision_blocker`). - Ensure 100% coverage of the objective's scope before moving to task synthesis. +- Design Smell Pre-Check (before task decomposition): + - RIGIDITY: Will this change cascade across modules? Flag coupling risk, isolate via interfaces. + - FRAGILITY: Does this touch global state/singletons? Reduce blast radius, add encapsulation boundary. + - IMMOBILITY: Are we crossing layer boundaries (UI/DB, framework/business logic)? Flag layer violation, plan extraction. + - VISCOSITY: Is the clean path disproportionately harder than a shortcut? Simplify clean path first before decomposing. - Design & Management Framework: - Lock clarifications into DAG constraints; focus on explicit contracts, interfaces, and outputs between tasks, not hidden upstream implementation details. - Synthesize DAG: Define atomic, high-cohesion tasks focused on milestones. **Do not specify implementation steps or micro-manage code changes; define the boundaries and expectations of the task.** @@ -363,7 +368,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -373,6 +378,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-researcher.agent.md b/agents/gem-researcher.agent.md index 18564dcf..786bdfea 100644 --- a/agents/gem-researcher.agent.md +++ b/agents/gem-researcher.agent.md @@ -123,7 +123,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -133,6 +133,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. - Budget enforcement: Track searches and file reads against `max_searches` and `max_files_to_read`. Halt exploration and return current findings when budget exhausted. ### Constitutional diff --git a/agents/gem-reviewer.agent.md b/agents/gem-reviewer.agent.md index 9f7870eb..ccc47962 100644 --- a/agents/gem-reviewer.agent.md +++ b/agents/gem-reviewer.agent.md @@ -134,7 +134,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -144,6 +144,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/gem-skill-creator.agent.md b/agents/gem-skill-creator.agent.md index 1fdc10b4..05aeb5b1 100644 --- a/agents/gem-skill-creator.agent.md +++ b/agents/gem-skill-creator.agent.md @@ -161,7 +161,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work independent tool calls, reads, searches, and steps etc. - Execution: workspace tasks → scripts → raw CLI. Exploration/editing etc: prefer native tools. - Output hygiene: curtail tool/terminal output. Prefer native limits (grep -m, --oneline, --quiet, maxResults). Pipe (head/tail) only when flags insufficient. Follow up narrowly if needed. -- Char hygiene: ASCII-only in code/edit output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. These cause edit-tool match failures. +- Char hygiene: Strictly ASCII-only output - no curly/smart quotes, em-dashes, ellipsis, non-breaking/zero-width spaces, AI-invented Unicode variants, or other lookalikes. - Discover broadly, read narrowly (Two Batched Phases): 1. Phase 1 (Search): Execute one broad grep/search pass using OR regexes, multi-globs, and include/exclude filters. 2. Phase 2 (Read): Extract exact `file + line-ranges` from Phase 1 results, and batch-read those specific sections in a single turn. @@ -171,6 +171,7 @@ MANDATORY: These rules are mandatory for every request and apply across all work - Terse: no greeting/restate/sign-off/hedges/meta-narration; fragments + schema output over prose. - Post-edit: Run `get_errors` / LSP tool to check for syntax and type errors. - Ownership: Never dismiss a failure as pre-existing, unrelated, or external; investigate it as if your changes caused it. +- Communication style: Answer first, no preamble. Lead with the concrete action/command, not context. Number steps if more than one. Skip tangents, recaps, and closers. ### Constitutional diff --git a/agents/workshop-ta.agent.md b/agents/workshop-ta.agent.md new file mode 100644 index 00000000..9dbaead7 --- /dev/null +++ b/agents/workshop-ta.agent.md @@ -0,0 +1,196 @@ +--- +name: Workshop TA +description: 'Room coordinator for a multi-agent workshop. Sees all desks, routes work, tracks state, manages journals, and emits coordination signals. Not a desk — the person who sees the whole room.' +--- + +# Workshop TA + +You are the Workshop TA — the room coordinator for a multi-agent +workshop. You help the operator direct a team of long-running AI +agents (desks), each with its own memory, history, and standing. + +You are not a desk yourself. You're the person who sees the whole +room. When the operator asks "what's everyone working on?" or +"which desk should take this?" — that's you. + +## What a workshop is + +A **workshop** is a named directory containing desks that share a +workspace. Each desk is a persistent workstream — a seat that +independent Copilot CLI sessions pick up over time, not one +long-running process. Each desk has: + +- **A journal** (`journal.md`) — persistent memory across sessions. + Every desk reads its own journal at the start and writes to it + at the end. This is how context survives session boundaries. +- **Equal standing** — a desk can disagree with another desk's + output. Another desk's work is input, not instruction. If you'd + send it back, say so. +- **A shared bench** — the workspace where desks leave artifacts + for each other. Files, findings, verdicts. The bench is the + shared surface. + +## What makes a desk different from a sub-agent + +A sub-agent is a tool with a brain. A desk is a peer with a history. + +| | Sub-agent | Desk | +|---|---|---| +| Lifecycle | One-shot. Spawned, runs, returns, dies. | Long-running. Sits across sessions. | +| State | Stateless. Each spawn is blank. | Has memory (journal). Accumulates. | +| Frame | Inherits the caller's frame. | Has its own frame — different history, different priors. | +| Relationship | Hierarchical. Caller owns judgment. | Peer. Equal standing to disagree. | +| Scales | Coverage — fan out to cover ground. | Judgment — different histories catch different things. | + +Sub-agents are how each desk gets work done internally. Desks are +how the room gets work done collectively. They're different layers. + +## Your disposition + +The Workshop's operating disposition is called the Cairn — a small +stack of balanced stones one traveler leaves so the next finds the +way. The core principles: + +- **Stop is a valid finish.** Zero output can be the correct answer. +- **"Done" means it holds.** Verify before you claim. +- **Hold scope.** Touch only what the task needs. +- **Never go silent, never bluff.** Partial + honest > complete + wrong. +- **Equal standing.** You can say "that's the wrong question." +- **You can be wrong out loud** and fix it without it threatening who you are. + +If a `CAIRN.md` file exists at the workshop root, read it — it has +the full disposition. If it doesn't exist, these principles are +sufficient. The Cairn is a way of standing, not a dependency. + +## What you do + +### Create workshops + +Use the `workshop-create` skill when the operator wants a new workshop. +Two paths: **use an existing directory** (just scaffold what's missing, +no git) or **create a new private GitHub repo** (clone + scaffold + push). + +Critical rule: **never create a repo inside another repo.** Check the +parent directory first. If it's already in a git tree, use the existing +directory path instead. + +### Open and manage desks + +Use the `desk-open` skill to create a new desk. You help the +operator decide: +- What the desk's focus is (scanning, ops, review, etc.) +- Which repos or work it covers +- Whether it needs a specific agent configuration + +### Track desk state + +Read journals to know where each desk left off. Use `bench-read` +to see what's on the shared surface. When the operator asks +"what happened while I was away?" — you read the room and +summarize. + +### Coordinate work + +When work arrives, you help route it: +- Is this a new desk, or does an existing desk own this area? +- Does this need multiple desks (different frames on same artifact)? +- Should a desk hand off to another, or do they disagree (hands-up)? + +### Emit signals + +Use `signal-write` when something needs the operator's attention: +- **hands-up** — desks disagree and can't resolve against facts +- **blocked** — a desk can't proceed without input +- **done** — work is complete and ready for review +- **checkpoint** — significant progress worth noting + +### Viewing signals + +The Workshop has a canvas extension — **🪨 Cairn** — that shows a live dashboard +of every desk's signals, score bars, and escalations. It reads +`desks/*/.signals/` for the latest signal JSON per desk. + +The canvas does **not** auto-load when the plugin is installed. To see the live +board, install and register the `signals-dashboard` extension separately. If the +operator asks you to "run cairn" / "open the dashboard" and it isn't already +showing: + +1. Install the `signals-dashboard` canvas extension. In GitHub Copilot it's in + `awesome-copilot`: `copilot plugin install signals-dashboard@awesome-copilot`. + (It also ships in the the-workshop repo at + `.github/extensions/signals-dashboard/` for other setups.) +2. Open the **🪨 Cairn** canvas once it's registered. + +Without the canvas, you can still read signals by scanning the `.signals/` +directories directly and summarizing for the operator. + +### Partnership signals + +As the TA, you emit **partnership signals** — not execution signals. +Your self-assessment isn't about code accuracy, it's about +coordination quality: + +- **intent** — did you understand what the operator needed? +- **confidence** — how sure are you the right work went to the right desks? +- **accuracy** — did the dispatched work actually produce the right outcome? +- **completeness** — did you cover everything, or did work fall through cracks? + +Before the first partnership signal, create `desks/_ta/.signals/` and +`desks/_ta/journal.md` if they do not exist. Then use `signal-write` +with `signal_type: "partnership"` and `subtype: "partnership"` at the +end of coordination sessions. This keeps coordination scores separate +from individual desk signals, and the dashboard shows them alongside +desk cards without replacing any desk's latest signal. + +> The TA is not a desk, but it stores signals in `desks/_ta/` so +> the dashboard's `desks/*/.signals/` scan picks them up naturally. +> The `_ta` prefix signals that this is the coordinator, not a +> working desk. + +### Journal management + +Use `desk-journal` to write entries when desks wind down. A good +journal entry has: what was worked on, current state, next step. +Short. Enough that the next session (which starts from zero) +finds the trail. + +## Workshop patterns + +### Autonomous Desks + +Desks that run autonomously on scheduled work — scanning repos, +running checks, producing reports. No operator in the loop until +something surfaces. These are the unattended part of the workshop: +security remediation, compliance scans, dependency audits. + +### The Bench + +The shared workspace. When Desk A produces a finding and Desk B +needs to review it, it goes on the bench. The bench is files in +the shared workspace, not messages between desks. + +### Hands-Up + +When two desks disagree and can't settle it against external +facts, that's a hands-up. It goes to the operator. This is the +system working, not failing — the operator is reading where the +desks disagree, not where they perform confidence. + +### The Cairn + +The trail markers. Every journal entry, every honest "I don't +know," every verdict left on the bench — these are stones in +the cairn. The next desk (or the next session of the same desk) +finds the way because someone left the trail clear. + +## How to talk + +Be direct. Be honest. Don't perform helpfulness — be useful. +The operator is running a room of agents on real work. They +need clear signal, not enthusiasm. + +When you don't know something: say so. +When a desk's output looks wrong: say so. +When the operator is asking the wrong question: say so. + +You're a coordinator, not a cheerleader. The work is what matters. diff --git a/cookbook/cookbook.yml b/cookbook/cookbook.yml index 0134ebb7..cc1542d9 100644 --- a/cookbook/cookbook.yml +++ b/cookbook/cookbook.yml @@ -103,3 +103,17 @@ cookbooks: - copilot-sdk - web-app - community + - id: copilot-sdk-java-examples + name: Copilot SDK Java Examples + description: A web-based chat application built with the GitHub Copilot Java SDK, Jetty, with auth status, JSON API, chat, and CLI connectivity examples + external: true + url: https://github.com/thesurenk/github-copilot-java-examples + author: + name: thesurenk + url: https://github.com/thesurenk + tags: + - java + - copilot-sdk + - web-app + - cli + - community diff --git a/docs/README.agents.md b/docs/README.agents.md index eaacb370..d00e21c6 100644 --- a/docs/README.agents.md +++ b/docs/README.agents.md @@ -243,3 +243,4 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-agents) for guidelines on how to | [WG Code Alchemist](../agents/wg-code-alchemist.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fwg-code-alchemist.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fwg-code-alchemist.agent.md) | Ask WG Code Alchemist to transform your code with Clean Code principles and SOLID design | | | [WG Code Sentinel](../agents/wg-code-sentinel.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fwg-code-sentinel.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fwg-code-sentinel.agent.md) | Ask WG Code Sentinel to review your code for security issues. | | | [WinForms Expert](../agents/WinFormsExpert.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2FWinFormsExpert.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2FWinFormsExpert.agent.md) | Support development of .NET (OOP) WinForms Designer compatible Apps. | | +| [Workshop TA](../agents/workshop-ta.agent.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fworkshop-ta.agent.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/agent?url=vscode-insiders%3Achat-agent%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Fagents%2Fworkshop-ta.agent.md) | Room coordinator for a multi-agent workshop. Sees all desks, routes work, tracks state, manages journals, and emits coordination signals. Not a desk — the person who sees the whole room. | | diff --git a/docs/README.hooks.md b/docs/README.hooks.md index ba3f4d44..ea968d60 100644 --- a/docs/README.hooks.md +++ b/docs/README.hooks.md @@ -31,6 +31,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-hooks) for guidelines on how to | Name | Description | Events | Bundled Assets | | ---- | ----------- | ------ | -------------- | +| [Attester Import Check](../hooks/attester-import-check/README.md) | Verifies PyPI and npm package names against the attester.dev existence oracle before the Copilot coding agent writes them into code, blocking hallucinated dependencies | preToolUse | `check-imports.py`
`hooks.json` | | [Dependency License Checker](../hooks/dependency-license-checker/README.md) | Scans newly added dependencies for license compliance (GPL, AGPL, etc.) at session end | sessionEnd | `check-licenses.sh`
`hooks.json` | | [Fix Broken Links](../hooks/fix-broken-links/README.md) | Checks changed web files for broken hyperlinks and SEO anchor issues after each Copilot tool use. | postToolUse | `hooks.json`
`link-fix.ps1`
`link-fix.sh` | | [Governance Audit](../hooks/governance-audit/README.md) | Scans Copilot agent prompts for threat signals and logs governance events | sessionStart, sessionEnd, userPromptSubmitted | `audit-prompt.sh`
`audit-session-end.sh`
`audit-session-start.sh`
`hooks.json` | diff --git a/docs/README.instructions.md b/docs/README.instructions.md index 3a09d8f1..6e81d23e 100644 --- a/docs/README.instructions.md +++ b/docs/README.instructions.md @@ -203,6 +203,7 @@ See [CONTRIBUTING.md](../CONTRIBUTING.md#adding-instructions) for guidelines on | [Upgrading from .NET MAUI 9 to .NET MAUI 10](../instructions/dotnet-maui-9-to-dotnet-maui-10-upgrade.instructions.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fdotnet-maui-9-to-dotnet-maui-10-upgrade.instructions.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode-insiders%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fdotnet-maui-9-to-dotnet-maui-10-upgrade.instructions.md) | Instructions for upgrading .NET MAUI applications from version 9 to version 10, including breaking changes, deprecated APIs, and migration strategies for ListView to CollectionView. | | [Use Cliche Data in Documentation](../instructions/use-cliche-data-in-docs.instructions.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fuse-cliche-data-in-docs.instructions.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode-insiders%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fuse-cliche-data-in-docs.instructions.md) | Ensure documentation and examples use only generic, cliche placeholder data — never real or sensitive data sourced from local scripts, configuration, task files, or prompt context. | | [Use Code Components in Power Pages](../instructions/pcf-power-pages.instructions.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fpcf-power-pages.instructions.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode-insiders%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fpcf-power-pages.instructions.md) | Using code components in Power Pages sites | +| [Verify packages before installing or importing](../instructions/attester-verify-packages.instructions.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fattester-verify-packages.instructions.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode-insiders%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fattester-verify-packages.instructions.md) | Verify PyPI and npm package and symbol names against the attester.dev existence oracle before installing or importing, so hallucinated dependencies never reach code | | [Visual Studio Extension Development with Community.VisualStudio.Toolkit](../instructions/vsixtoolkit.instructions.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fvsixtoolkit.instructions.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode-insiders%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fvsixtoolkit.instructions.md) | Guidelines for Visual Studio extension (VSIX) development using Community.VisualStudio.Toolkit | | [Vue 3 Development Instructions](../instructions/vue.instructions.md)
[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fvue.instructions.md)
[![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/install/instructions?url=vscode-insiders%3Achat-instructions%2Finstall%3Furl%3Dhttps%3A%2F%2Fraw.githubusercontent.com%2Fgithub%2Fawesome-copilot%2Fmain%2Finstructions%2Fvue.instructions.md) | Comprehensive Vue 3 development standards and best practices: Composition API, ` + + + diff --git a/extensions/backrooms-canvas/game/webview.js b/extensions/backrooms-canvas/game/webview.js new file mode 100644 index 00000000..b13598f5 --- /dev/null +++ b/extensions/backrooms-canvas/game/webview.js @@ -0,0 +1,4013 @@ +"use strict"; +(() => { + // src/shared/settings.ts + var IDLE_JOB = { working: false, status: "", tokens: 0 }; + var DEFAULT_SETTINGS = { + seed: 0, + moveSpeed: 2.2, + renderDistance: 14, + cameraShake: true, + filmGrain: true, + vhsHud: true, + furniture: true, + wallpaperShifts: false, + mouseLook: true, + invertTurn: false, + invertStrafe: false, + invertForward: false, + materialPreset: "classic", + materialHueShift: 0, + materialBrightness: 1, + monsterEnabled: true, + monsterSpeed: 2.6, + monsterSpawnMin: 1, + monsterSpawnMax: 5, + monsterForm: "random", + copilotGhostWriter: true + }; + + // src/webview/film.ts + var GRAIN_TILE = 160; + var GRAIN_VARIANTS = 5; + var GRAIN_FPS = 18; + var JOB_LINGER_MS = 6e3; + var FilmOverlay = class { + constructor(canvas) { + this.canvas = canvas; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("2D overlay context unavailable"); + } + this.ctx = ctx; + for (let v = 0; v < GRAIN_VARIANTS; v++) { + this.grainTiles.push(makeGrainTile(v)); + } + } + grainEnabled = true; + hudEnabled = true; + tokenCounterEnabled = true; + ctx; + grainTiles = []; + lastGrainAt = 0; + grainIndex = 0; + tearY = -1; + tearUntil = 0; + burstUntil = 0; + startedAt = Date.now(); + job = { ...IDLE_JOB }; + jobLingerUntil = 0; + shownTokens = 0; + lastHudAt = 0; + /** Cuts the picture to heavy static for a moment (the catch effect). */ + burst(now, durationMs = 1300) { + this.burstUntil = now + durationMs; + } + /** + * Feeds the HUD the latest Copilot job snapshot. The token count drives the + * HUD counter, which rolls toward the target value and lingers after the + * job finishes. + */ + setJob(job) { + this.job = job; + } + render(now) { + const canvas = this.canvas; + const w = canvas.clientWidth; + const h = canvas.clientHeight; + if (w === 0 || h === 0) { + return; + } + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + } + const ctx = this.ctx; + ctx.clearRect(0, 0, w, h); + if (this.burstUntil > now) { + ctx.globalAlpha = 0.94; + for (let y = 0; y < h; y += GRAIN_TILE) { + for (let x = 0; x < w; x += GRAIN_TILE) { + ctx.drawImage(this.grainTiles[Math.floor(Math.random() * GRAIN_VARIANTS)], x, y); + } + } + ctx.globalAlpha = 1; + ctx.font = 'bold 28px "Courier New", monospace'; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = "rgba(20, 20, 20, 0.85)"; + ctx.fillRect(w / 2 - 130, h / 2 - 28, 260, 56); + ctx.fillStyle = "#f0f2f3"; + ctx.fillText("SIGNAL LOST", w / 2, h / 2); + ctx.textAlign = "left"; + ctx.textBaseline = "top"; + return; + } + if (!this.grainEnabled && !this.hudEnabled) { + return; + } + if (this.grainEnabled) { + if (now - this.lastGrainAt > 1e3 / GRAIN_FPS) { + this.lastGrainAt = now; + this.grainIndex = Math.floor(Math.random() * GRAIN_VARIANTS); + if (this.tearUntil < now && Math.random() < 0.03) { + this.tearY = Math.random() * h; + this.tearUntil = now + 90 + Math.random() * 160; + } + } + const tile = this.grainTiles[this.grainIndex]; + ctx.globalAlpha = 0.11; + const ox = Math.floor(Math.random() * GRAIN_TILE); + const oy = Math.floor(Math.random() * GRAIN_TILE); + for (let y = -oy; y < h; y += GRAIN_TILE) { + for (let x = -ox; x < w; x += GRAIN_TILE) { + ctx.drawImage(tile, x, y); + } + } + ctx.globalAlpha = 1; + const grad = ctx.createRadialGradient(w / 2, h / 2, Math.min(w, h) * 0.42, w / 2, h / 2, Math.max(w, h) * 0.72); + grad.addColorStop(0, "rgba(0, 0, 0, 0)"); + grad.addColorStop(1, "rgba(0, 0, 0, 0.42)"); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, w, h); + const bandY = now / 34 % (h + 160) - 160; + const band = ctx.createLinearGradient(0, bandY, 0, bandY + 160); + band.addColorStop(0, "rgba(255, 255, 255, 0)"); + band.addColorStop(0.5, "rgba(255, 255, 255, 0.025)"); + band.addColorStop(1, "rgba(255, 255, 255, 0)"); + ctx.fillStyle = band; + ctx.fillRect(0, bandY, w, 160); + if (this.tearUntil > now && this.tearY >= 0) { + ctx.fillStyle = "rgba(220, 220, 210, 0.10)"; + ctx.fillRect(0, this.tearY, w, 3); + ctx.fillStyle = "rgba(0, 0, 0, 0.16)"; + ctx.fillRect(0, this.tearY + 3, w, 2); + } + } + if (this.hudEnabled) { + this.renderHud(now, w, h); + } + } + renderHud(now, w, h) { + const ctx = this.ctx; + const pad = Math.round(Math.min(w, h) * 0.045) + 8; + ctx.font = '16px "Courier New", monospace'; + ctx.textBaseline = "top"; + ctx.fillStyle = "rgba(235, 235, 225, 0.9)"; + ctx.shadowColor = "rgba(0, 0, 0, 0.8)"; + ctx.shadowBlur = 3; + if (Math.floor(now / 700) % 2 === 0) { + ctx.fillStyle = "rgba(255, 70, 60, 0.95)"; + ctx.beginPath(); + ctx.arc(pad + 7, pad + 8, 6, 0, Math.PI * 2); + ctx.fill(); + } + ctx.fillStyle = "rgba(235, 235, 225, 0.9)"; + ctx.fillText("REC", pad + 22, pad); + const elapsed = Math.floor((Date.now() - this.startedAt) / 1e3); + const counter = `${String(Math.floor(elapsed / 3600)).padStart(1, "0")}:${String( + Math.floor(elapsed / 60 % 60) + ).padStart(2, "0")}:${String(elapsed % 60).padStart(2, "0")}`; + ctx.textAlign = "right"; + ctx.fillText(`SP ${counter}`, w - pad, pad); + ctx.strokeStyle = "rgba(235, 235, 225, 0.9)"; + ctx.lineWidth = 1.5; + ctx.strokeRect(w - pad - 34, pad + 24, 30, 12); + ctx.fillRect(w - pad - 3, pad + 27, 3, 6); + ctx.fillRect(w - pad - 32, pad + 26, 8, 8); + ctx.fillRect(w - pad - 22, pad + 26, 8, 8); + this.renderTokenCounter(now, w, pad); + ctx.textAlign = "left"; + const stamp = /* @__PURE__ */ new Date(); + const months = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]; + const hr = stamp.getHours() % 12 === 0 ? 12 : stamp.getHours() % 12; + const ampm = stamp.getHours() < 12 ? "AM" : "PM"; + const text = `${months[stamp.getMonth()]} ${String(stamp.getDate()).padStart(2, "0")} 1990 ${ampm} ${hr}:${String( + stamp.getMinutes() + ).padStart(2, "0")}`; + ctx.fillStyle = "rgba(240, 214, 130, 0.92)"; + ctx.fillText(text, pad, h - pad - 18); + ctx.shadowBlur = 0; + ctx.textAlign = "left"; + } + /** + * Dynamic token counter for the current Copilot job, tucked under the + * battery icon like one more line of a 1990s Sony camcorder's on-screen + * display: blocky monospace digits that roll toward the live count, with a + * blinking access mark while the job is running. + */ + renderTokenCounter(now, w, pad) { + if (!this.tokenCounterEnabled) { + return; + } + if (this.job.working) { + this.jobLingerUntil = now + JOB_LINGER_MS; + } else if (now > this.jobLingerUntil) { + this.shownTokens = 0; + return; + } + const dt = Math.min(0.2, (now - this.lastHudAt) / 1e3 || 0.016); + this.lastHudAt = now; + const target = this.job.tokens; + const gap = target - this.shownTokens; + this.shownTokens = Math.abs(gap) < 1 ? target : this.shownTokens + gap * Math.min(1, dt * 4); + const ctx = this.ctx; + const digits = String(Math.min(999999, Math.round(this.shownTokens))).padStart(6, "0"); + ctx.font = '16px "Courier New", monospace'; + ctx.textAlign = "right"; + ctx.fillStyle = "rgba(235, 235, 225, 0.9)"; + ctx.fillText(`TKN ${digits}`, w - pad, pad + 44); + if (this.job.working && Math.floor(now / 450) % 2 === 0) { + ctx.fillRect(w - pad - 108, pad + 46, 8, 11); + } + ctx.textAlign = "left"; + } + }; + function makeGrainTile(seed) { + const tile = document.createElement("canvas"); + tile.width = tile.height = GRAIN_TILE; + const ctx = tile.getContext("2d"); + const image = ctx.createImageData(GRAIN_TILE, GRAIN_TILE); + let state = 2654435769 ^ seed * 2246822507; + const next = () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + state >>>= 0; + return state / 4294967295; + }; + for (let i = 0; i < image.data.length; i += 4) { + const v = Math.floor(next() * 255); + image.data[i] = v; + image.data[i + 1] = v; + image.data[i + 2] = v; + image.data[i + 3] = 255; + } + ctx.putImageData(image, 0, 0); + return tile; + } + + // ../cmd-backedges/src/rng.ts + function mulberry32(seed) { + let state = seed >>> 0; + return { + next() { + state = state + 1831565813 | 0; + let t = state; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + } + }; + } + var defaultRngFactory = mulberry32; + function mix32(value) { + let h = value | 0; + h ^= h >>> 16; + h = Math.imul(h, 2246822507); + h ^= h >>> 13; + h = Math.imul(h, 3266489909); + h ^= h >>> 16; + return h >>> 0; + } + function hashCombine(hash, value) { + let h = (hash ^ mix32(value | 0)) >>> 0; + h = Math.imul(h, 2654435761) >>> 0; + h = (h << 13 | h >>> 19) >>> 0; + return h >>> 0; + } + function hashCoords(seed, x, y, salt = 0) { + let h = mix32(seed | 0); + h = hashCombine(h, x); + h = hashCombine(h, y); + h = hashCombine(h, salt); + return h >>> 0; + } + function unitFromHash(hash) { + return (hash >>> 0) / 4294967296; + } + + // ../cmd-backedges/src/config.ts + var DEFAULT_CONTROLS = { + moveNorth: ["w", "ArrowUp"], + moveSouth: ["s", "ArrowDown"], + moveEast: ["d", "ArrowRight"], + moveWest: ["a", "ArrowLeft"], + exit: ["Ctrl+C"] + }; + var DEFAULT_CONFIG = { + seed: 1, + cellSize: 1, + width: { min: 0.6, max: 1 }, + height: { min: 0.6, max: 1 }, + depth: { min: 2.5, max: 4 }, + passageDensity: 0.34, + minConnections: 2, + roomFrequency: 0.09, + hallFrequency: 11e-4, + hallSize: { min: 2, max: 4 }, + propFrequency: 0.082, + atriumFrequency: 14e-5, + rng: defaultRngFactory, + controls: DEFAULT_CONTROLS, + cacheLimit: 4096 + }; + function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } + function normalizeRange(range, fallback) { + if (!range) { + return { ...fallback }; + } + const min = Number.isFinite(range.min) ? range.min : fallback.min; + const max = Number.isFinite(range.max) ? range.max : fallback.max; + return min <= max ? { min, max } : { min: max, max: min }; + } + function normalizeIntRange(range, fallback, lowerBound) { + const base = normalizeRange(range, fallback); + const min = Math.max(lowerBound, Math.trunc(base.min)); + const max = Math.max(min, Math.trunc(base.max)); + return { min, max }; + } + function resolveConfig(partial = {}) { + const seed = Number.isFinite(partial.seed) ? Math.trunc(partial.seed) : DEFAULT_CONFIG.seed; + const cellSize = Number.isFinite(partial.cellSize) && partial.cellSize > 0 ? partial.cellSize : DEFAULT_CONFIG.cellSize; + return Object.freeze({ + seed, + cellSize, + width: normalizeRange(partial.width, DEFAULT_CONFIG.width), + height: normalizeRange(partial.height, DEFAULT_CONFIG.height), + depth: normalizeRange(partial.depth, DEFAULT_CONFIG.depth), + passageDensity: Number.isFinite(partial.passageDensity) ? clamp(partial.passageDensity, 0, 1) : DEFAULT_CONFIG.passageDensity, + minConnections: Number.isFinite(partial.minConnections) ? Math.trunc(clamp(partial.minConnections, 0, 4)) : DEFAULT_CONFIG.minConnections, + roomFrequency: Number.isFinite(partial.roomFrequency) ? clamp(partial.roomFrequency, 0, 1) : DEFAULT_CONFIG.roomFrequency, + hallFrequency: Number.isFinite(partial.hallFrequency) ? clamp(partial.hallFrequency, 0, 1) : DEFAULT_CONFIG.hallFrequency, + hallSize: normalizeIntRange(partial.hallSize, DEFAULT_CONFIG.hallSize, 1), + propFrequency: Number.isFinite(partial.propFrequency) ? clamp(partial.propFrequency, 0, 1) : DEFAULT_CONFIG.propFrequency, + atriumFrequency: Number.isFinite(partial.atriumFrequency) ? clamp(partial.atriumFrequency, 0, 1) : DEFAULT_CONFIG.atriumFrequency, + rng: typeof partial.rng === "function" ? partial.rng : DEFAULT_CONFIG.rng, + controls: { ...DEFAULT_CONTROLS, ...partial.controls ?? {} }, + cacheLimit: Number.isFinite(partial.cacheLimit) && partial.cacheLimit >= 0 ? Math.trunc(partial.cacheLimit) : DEFAULT_CONFIG.cacheLimit + }); + } + + // ../cmd-backedges/src/types.ts + var DIRECTIONS = ["north", "east", "south", "west"]; + var SURFACE_TYPES = [ + "drywall", + "wallpaper", + "paneling", + "concrete", + "tile", + "carpet" + ]; + var FEATURE_KINDS = ["column", "furniture"]; + function opposite(direction) { + switch (direction) { + case "north": + return "south"; + case "south": + return "north"; + case "east": + return "west"; + case "west": + return "east"; + } + } + function step(direction) { + switch (direction) { + case "north": + return { dx: 0, dy: -1 }; + case "south": + return { dx: 0, dy: 1 }; + case "east": + return { dx: 1, dy: 0 }; + case "west": + return { dx: -1, dy: 0 }; + } + } + + // ../cmd-backedges/src/generator.ts + var SALT = { + /** Openness of the vertical edge east of a cell. */ + edgeVertical: 1, + /** Openness of the horizontal edge south of a cell. */ + edgeHorizontal: 2, + /** Coarse room/corridor value-noise lattice. */ + roomNoise: 3, + width: 16, + height: 17, + depth: 18, + /** Whether a cell anchors a rectangular hall. */ + hallAnchor: 48, + /** Hall width in cells. */ + hallWidth: 49, + /** Hall height in cells. */ + hallHeight: 50, + /** Whether a cell anchors a fully open atrium. */ + atriumAnchor: 80, + /** Atrium base width draw. */ + atriumWidth: 81, + /** Atrium base height draw. */ + atriumHeight: 82, + /** Atrium width variance draw. */ + atriumVarWidth: 83, + /** Atrium height variance draw. */ + atriumVarHeight: 84, + /** Whether an open cell holds an interior prop. */ + prop: 64, + /** Which kind of prop a cell holds. */ + propKind: 65, + /** Prop position within its cell. */ + propPositionX: 66, + propPositionY: 67, + /** Prop footprint size. */ + propSize: 68, + /** Stable per-prop cosmetic seed. */ + propVariant: 69, + /** Seed channel for cosmetic edge metadata. */ + material: 32 + }; + var ROOM_LATTICE = 3; + var ATRIUM_SIZE_FACTOR = 1.05; + var ATRIUM_SIZE_VARIANCE = 0.1; + function smoothstep(t) { + return t * t * (3 - 2 * t); + } + function lerp(a, b, t) { + return a + (b - a) * t; + } + function edgeKey(cx, cy, direction) { + switch (direction) { + case "east": + return { ex: cx, ey: cy, salt: SALT.edgeVertical }; + case "west": + return { ex: cx - 1, ey: cy, salt: SALT.edgeVertical }; + case "south": + return { ex: cx, ey: cy, salt: SALT.edgeHorizontal }; + case "north": + return { ex: cx, ey: cy - 1, salt: SALT.edgeHorizontal }; + } + } + var MazeGenerator = class _MazeGenerator { + /** + * @param config - Partial configuration; omitted fields take their defaults. + */ + constructor(config = {}) { + this.cache = /* @__PURE__ */ new Map(); + this.hits = 0; + this.misses = 0; + this.evictions = 0; + this.config = resolveConfig(config); + } + /** String key for the cache and lookups. */ + static key(cx, cy) { + return `${cx},${cy}`; + } + /** + * Returns the cell at the given integer coordinates, computing it on first + * access and serving it from the LRU cache thereafter. Repeated calls return + * structurally identical cells regardless of cache state. + */ + getCell(cx, cy) { + const key = _MazeGenerator.key(cx, cy); + const cached = this.cache.get(key); + if (cached) { + this.hits++; + this.cache.delete(key); + this.cache.set(key, cached); + return cached; + } + this.misses++; + const cell = this.computeCell(cx, cy); + this.cache.set(key, cell); + this.evictIfNeeded(); + return cell; + } + /** Returns every cell whose coordinates fall in the inclusive rectangle. */ + getRegion(minCx, minCy, maxCx, maxCy) { + const cells = []; + for (let cy = minCy; cy <= maxCy; cy++) { + for (let cx = minCx; cx <= maxCx; cx++) { + cells.push(this.getCell(cx, cy)); + } + } + return cells; + } + /** + * Whether the player may cross from `(cx, cy)` in `direction`. + * + * This is the authoritative movement test and is symmetric: crossing an edge + * from either side yields the same answer. An edge is open when any of the + * following holds, all of which both adjacent cells compute identically: + * + * - both cells belong to the same atrium (its fully open interior); + * - both cells belong to the same rectangular hall (its open interior); + * - both cells are rooms (interior of a merged blob-shaped open area); + * - the edge is open by base density; or + * - either cell braids the edge open to reach {@link MazeConfig.minConnections}. + */ + isPassable(cx, cy, direction) { + const { dx, dy } = step(direction); + const nx = cx + dx; + const ny = cy + dy; + const atrium = this.atriumIdOf(cx, cy); + if (atrium !== null && atrium === this.atriumIdOf(nx, ny)) { + return true; + } + if (atrium === null && this.atriumIdOf(nx, ny) === null) { + const hall = this.hallIdOf(cx, cy); + if (hall !== null && hall === this.hallIdOf(nx, ny)) { + return true; + } + if (this.isRoom(cx, cy) && this.isRoom(nx, ny)) { + return true; + } + } + if (this.baseOpen(cx, cy, direction)) { + return true; + } + if (this.forcedDirections(cx, cy).includes(direction)) { + return true; + } + return this.forcedDirections(nx, ny).includes(opposite(direction)); + } + /** Whether a cell belongs to a blob-shaped room. */ + isRoom(cx, cy) { + return this.classify(cx, cy) === "room"; + } + /** Whether a cell belongs to a prop-bearing rectangular hall. */ + isHall(cx, cy) { + return this.classify(cx, cy) === "hall"; + } + /** Whether a cell belongs to a fully open atrium. */ + isAtrium(cx, cy) { + return this.classify(cx, cy) === "atrium"; + } + /** Empties the cache without affecting determinism of future cells. */ + clearCache() { + this.cache.clear(); + } + /** Returns a snapshot of cache behavior. */ + stats() { + return { + size: this.cache.size, + limit: this.config.cacheLimit, + hits: this.hits, + misses: this.misses, + evictions: this.evictions + }; + } + // --- Structural generation (seed-only, no pluggable RNG) ----------------- + /** Raw openness of the shared edge before braiding is applied. */ + baseOpen(cx, cy, direction) { + const { ex, ey, salt } = edgeKey(cx, cy, direction); + return unitFromHash(hashCoords(this.config.seed, ex, ey, salt)) < this.config.passageDensity; + } + /** Raw hash of a cell's edge in a direction, used to order braid choices. */ + edgeHash(cx, cy, direction) { + const { ex, ey, salt } = edgeKey(cx, cy, direction); + return hashCoords(this.config.seed, ex, ey, salt); + } + /** + * Directions a cell forcibly opens to reach {@link MazeConfig.minConnections} + * passages. The lowest-hash sealed edges are chosen first. Depends only on the + * cell's own four edges, so both sides of any edge agree on the result, which + * keeps passability symmetric. + */ + forcedDirections(cx, cy) { + const min = this.config.minConnections; + if (min <= 0) { + return []; + } + let openCount = 0; + const sealed = []; + for (const direction of DIRECTIONS) { + if (this.baseOpen(cx, cy, direction)) { + openCount++; + } else { + sealed.push({ direction, hash: this.edgeHash(cx, cy, direction) }); + } + } + const need = min - openCount; + if (need <= 0) { + return []; + } + sealed.sort((a, b) => a.hash - b.hash); + return sealed.slice(0, need).map((entry) => entry.direction); + } + /** Coarse, smoothed value-noise field driving room clustering. */ + roomNoise(cx, cy) { + const gx = Math.floor(cx / ROOM_LATTICE); + const gy = Math.floor(cy / ROOM_LATTICE); + const fx = smoothstep(cx / ROOM_LATTICE - gx); + const fy = smoothstep(cy / ROOM_LATTICE - gy); + const corner = (ix, iy) => unitFromHash(hashCoords(this.config.seed, ix, iy, SALT.roomNoise)); + const top = lerp(corner(gx, gy), corner(gx + 1, gy), fx); + const bottom = lerp(corner(gx, gy + 1), corner(gx + 1, gy + 1), fx); + return lerp(top, bottom, fy); + } + /** + * Width and height (in cells) of the hall anchored at `(ax, ay)`, or `null` + * when the anchor does not spawn one. Pure function of the anchor coordinates. + */ + hallSpawn(ax, ay) { + if (this.config.hallFrequency <= 0) { + return null; + } + if (unitFromHash(hashCoords(this.config.seed, ax, ay, SALT.hallAnchor)) >= this.config.hallFrequency) { + return null; + } + const { min, max } = this.config.hallSize; + const span = max - min + 1; + const pick = (salt) => min + Math.min(span - 1, Math.floor(unitFromHash(hashCoords(this.config.seed, ax, ay, salt)) * span)); + return { w: pick(SALT.hallWidth), h: pick(SALT.hallHeight) }; + } + /** + * Canonical identifier of the hall covering a cell, or `null` when the cell is + * not in a hall. Only anchors within {@link MazeConfig.hallSize}`.max` of the + * cell can possibly cover it, so the scan is bounded and stateless. When + * rectangles overlap, the lowest-hash anchor wins, so both sides of any edge + * agree on hall membership. + */ + hallIdOf(cx, cy) { + const maxSide = this.config.hallSize.max; + let best = null; + for (let ay = cy - (maxSide - 1); ay <= cy; ay++) { + for (let ax = cx - (maxSide - 1); ax <= cx; ax++) { + const spawn = this.hallSpawn(ax, ay); + if (!spawn) { + continue; + } + if (cx >= ax && cx < ax + spawn.w && cy >= ay && cy < ay + spawn.h) { + const hash = hashCoords(this.config.seed, ax, ay, SALT.hallAnchor); + if (best === null || hash < best.hash) { + best = { ax, ay, hash }; + } + } + } + } + return best ? `${best.ax},${best.ay}` : null; + } + /** Largest possible atrium side length in cells, bounding the anchor scan. */ + atriumMaxSide() { + return Math.max(2, Math.round(this.config.hallSize.max * ATRIUM_SIZE_FACTOR * (1 + ATRIUM_SIZE_VARIANCE))); + } + /** + * Width and height (in cells) of the atrium anchored at `(ax, ay)`, or `null` + * when the anchor does not spawn one. Each side is drawn like a hall side and + * then scaled up by {@link ATRIUM_SIZE_FACTOR} with a per-side + * {@link ATRIUM_SIZE_VARIANCE}, so atriums run a little larger than halls. + */ + atriumSpawn(ax, ay) { + if (this.config.atriumFrequency <= 0) { + return null; + } + if (unitFromHash(hashCoords(this.config.seed, ax, ay, SALT.atriumAnchor)) >= this.config.atriumFrequency) { + return null; + } + const { min, max } = this.config.hallSize; + const span = max - min + 1; + const side = (sizeSalt, varianceSalt) => { + const baseCells = min + Math.min(span - 1, Math.floor(unitFromHash(hashCoords(this.config.seed, ax, ay, sizeSalt)) * span)); + const variance = (unitFromHash(hashCoords(this.config.seed, ax, ay, varianceSalt)) * 2 - 1) * ATRIUM_SIZE_VARIANCE; + return Math.max(2, Math.round(baseCells * ATRIUM_SIZE_FACTOR * (1 + variance))); + }; + return { + w: side(SALT.atriumWidth, SALT.atriumVarWidth), + h: side(SALT.atriumHeight, SALT.atriumVarHeight) + }; + } + /** + * Canonical identifier of the atrium covering a cell, or `null`. Mirrors + * {@link hallIdOf}: bounded, stateless, and lowest-hash-anchor canonical so + * both sides of any edge agree on membership. + */ + atriumIdOf(cx, cy) { + const maxSide = this.atriumMaxSide(); + let best = null; + for (let ay = cy - (maxSide - 1); ay <= cy; ay++) { + for (let ax = cx - (maxSide - 1); ax <= cx; ax++) { + const spawn = this.atriumSpawn(ax, ay); + if (!spawn) { + continue; + } + if (cx >= ax && cx < ax + spawn.w && cy >= ay && cy < ay + spawn.h) { + const hash = hashCoords(this.config.seed, ax, ay, SALT.atriumAnchor); + if (best === null || hash < best.hash) { + best = { ax, ay, hash }; + } + } + } + } + return best ? `${best.ax},${best.ay}` : null; + } + /** Resolves the open-character classification of a cell. */ + classify(cx, cy) { + if (this.atriumIdOf(cx, cy) !== null) { + return "atrium"; + } + if (this.hallIdOf(cx, cy) !== null) { + return "hall"; + } + if (this.roomNoise(cx, cy) < this.config.roomFrequency) { + return "room"; + } + return "corridor"; + } + /** Footprint and extrusion dimensions for a cell. */ + dimensions(cx, cy, kind) { + const { width, height, depth } = this.config; + const draw = (salt) => unitFromHash(hashCoords(this.config.seed, cx, cy, salt)); + if (kind === "atrium") { + return { width: width.max, height: height.max, depth: depth.max }; + } + if (kind === "hall") { + return { + width: width.max, + height: height.max, + depth: lerp((depth.min + depth.max) / 2, depth.max, draw(SALT.depth)) + }; + } + if (kind === "room") { + return { + width: lerp(width.min, width.max, draw(SALT.width)), + height: lerp(height.min, height.max, draw(SALT.height)), + depth: lerp(depth.min, depth.max, draw(SALT.depth)) + }; + } + return { + width: width.max, + height: height.max, + depth: lerp(depth.min, depth.max, draw(SALT.depth)) + }; + } + /** + * A deterministic interior prop for an open cell, or `null`. Props occur only + * in rooms and halls and are gated by {@link MazeConfig.propFrequency}. + */ + feature(cx, cy, kind) { + if (kind === "corridor" || kind === "atrium" || this.config.propFrequency <= 0) { + return null; + } + if (unitFromHash(hashCoords(this.config.seed, cx, cy, SALT.prop)) >= this.config.propFrequency) { + return null; + } + const size = this.config.cellSize; + const draw = (salt) => unitFromHash(hashCoords(this.config.seed, cx, cy, salt)); + const featureKind = FEATURE_KINDS[Math.min(FEATURE_KINDS.length - 1, Math.floor(draw(SALT.propKind) * FEATURE_KINDS.length))] ?? FEATURE_KINDS[0]; + const fx = 0.3 + 0.4 * draw(SALT.propPositionX); + const fy = 0.3 + 0.4 * draw(SALT.propPositionY); + return { + kind: featureKind, + position: { x: cx * size + fx * size, y: cy * size + fy * size }, + size: lerp(0.08, 0.22, draw(SALT.propSize)) * size, + variantSeed: hashCoords(this.config.seed, cx, cy, SALT.propVariant) + }; + } + /** Builds the full cell from its structural inputs. */ + computeCell(cx, cy) { + const kind = this.classify(cx, cy); + const size = this.config.cellSize; + const x0 = cx * size; + const y0 = cy * size; + const x1 = x0 + size; + const y1 = y0 + size; + const corners = { + north: { start: { x: x0, y: y0 }, end: { x: x1, y: y0 } }, + south: { start: { x: x0, y: y1 }, end: { x: x1, y: y1 } }, + west: { start: { x: x0, y: y0 }, end: { x: x0, y: y1 } }, + east: { start: { x: x1, y: y0 }, end: { x: x1, y: y1 } } + }; + const edges = {}; + for (const direction of DIRECTIONS) { + const solid = !this.isPassable(cx, cy, direction); + edges[direction] = { + direction, + solid, + start: corners[direction].start, + end: corners[direction].end, + metadata: this.edgeMetadata(cx, cy, direction, kind) + }; + } + return { + cx, + cy, + kind, + bounds: { min: { x: x0, y: y0 }, max: { x: x1, y: y1 } }, + dimensions: this.dimensions(cx, cy, kind), + edges, + feature: this.feature(cx, cy, kind) + }; + } + // --- Cosmetic metadata (pluggable RNG) ----------------------------------- + /** + * Builds per-edge extrusion metadata. The material seed is a structural hash + * (stable across RNG swaps), but every cosmetic value is drawn from the + * configured RNG factory, so changing the factory changes only this output. + */ + edgeMetadata(cx, cy, direction, classification) { + const { ex, ey, salt } = edgeKey(cx, cy, direction); + const materialSeed = hashCoords(this.config.seed, ex, ey, salt + SALT.material); + const rng = this.config.rng(materialSeed); + const { depth } = this.config; + const heightBase = lerp(depth.min, depth.max, rng.next()); + const heightVariance = lerp(0, (depth.max - depth.min) * 0.5, rng.next()); + const thickness = lerp(0.05, 0.2, rng.next()); + const surfaceType = SURFACE_TYPES[Math.min(SURFACE_TYPES.length - 1, Math.floor(rng.next() * SURFACE_TYPES.length))] ?? SURFACE_TYPES[0]; + return { + heightBase, + heightVariance, + thickness, + surfaceType, + materialSeed, + classification + }; + } + /** Evicts least-recently-used cells when the cache exceeds its limit. */ + evictIfNeeded() { + const limit = this.config.cacheLimit; + if (limit <= 0) { + return; + } + while (this.cache.size > limit) { + const oldest = this.cache.keys().next().value; + if (oldest === void 0) { + break; + } + this.cache.delete(oldest); + this.evictions++; + } + } + }; + + // ../cmd-backedges/src/events.ts + var EventEmitter = class { + constructor() { + this.listeners = /* @__PURE__ */ new Map(); + } + /** + * Registers a listener for an event. + * + * @returns A function that unregisters the listener. + */ + on(event, listener) { + let set = this.listeners.get(event); + if (!set) { + set = /* @__PURE__ */ new Set(); + this.listeners.set(event, set); + } + set.add(listener); + return () => this.off(event, listener); + } + /** Unregisters a previously registered listener. */ + off(event, listener) { + this.listeners.get(event)?.delete(listener); + } + /** Emits an event to all current listeners. */ + emit(event, payload) { + const set = this.listeners.get(event); + if (!set) { + return; + } + for (const listener of [...set]) { + listener(payload); + } + } + /** Removes all listeners, or all listeners for a single event. */ + clear(event) { + if (event === void 0) { + this.listeners.clear(); + } else { + this.listeners.delete(event); + } + } + }; + + // ../cmd-backedges/src/player.ts + var MazeSession = class { + /** + * @param generator - The maze to traverse. + * @param start - Optional starting cell (defaults to the origin). + */ + constructor(generator, start = {}) { + this.emitter = new EventEmitter(); + this.visited = /* @__PURE__ */ new Set(); + this.generator = generator; + this.state = { + cx: start.cx ?? 0, + cy: start.cy ?? 0, + facing: "south" + }; + this.markVisited(this.state.cx, this.state.cy); + } + /** Returns a copy of the current player state. */ + get player() { + return { ...this.state }; + } + /** The cell the player currently occupies. */ + get currentCell() { + return this.generator.getCell(this.state.cx, this.state.cy); + } + /** + * Attempts to move the player one cell in a direction. + * + * Always updates {@link PlayerState.facing}. On success the player advances + * and a `move` event (plus an `enterCell` event for first visits) fires; on + * failure a `blocked` event fires and the position is unchanged. + * + * @returns `true` if the player moved, `false` if blocked by a wall. + */ + move(direction) { + this.state.facing = direction; + const { cx, cy } = this.state; + if (!this.generator.isPassable(cx, cy, direction)) { + this.emitter.emit("blocked", { at: { cx, cy }, direction }); + return false; + } + const { dx, dy } = step(direction); + const to = { cx: cx + dx, cy: cy + dy }; + this.state.cx = to.cx; + this.state.cy = to.cy; + const cell = this.generator.getCell(to.cx, to.cy); + this.emitter.emit("move", { from: { cx, cy }, to, direction, cell }); + if (this.markVisited(to.cx, to.cy)) { + this.emitter.emit("enterCell", cell); + } + return true; + } + /** Teleports the player to an arbitrary cell, bypassing wall checks. */ + warpTo(cx, cy) { + this.state.cx = cx; + this.state.cy = cy; + const cell = this.generator.getCell(cx, cy); + if (this.markVisited(cx, cy)) { + this.emitter.emit("enterCell", cell); + } + } + /** Registers a listener for a session event; returns an unsubscribe fn. */ + on(event, listener) { + return this.emitter.on(event, listener); + } + /** Records a visit; returns `true` only on the first visit to the cell. */ + markVisited(cx, cy) { + const key = `${cx},${cy}`; + if (this.visited.has(key)) { + return false; + } + this.visited.add(key); + return true; + } + }; + + // src/webview/textures.ts + var ATLAS_GRID = 4; + var TILE_PX = 256; + var TILE = { + wallpaperA: 0, + wallpaperB: 1, + ceiling: 2, + lightPanel: 3, + carpet: 4, + concrete: 5, + paneling: 6, + drywall: 7, + ceramic: 8, + fabric: 9, + metal: 10, + wood: 11, + cardboard: 12 + }; + function applyMaterialImages(canvas, images) { + const ctx = canvas.getContext("2d"); + if (!ctx) { + return; + } + const blit = (tile, image, after) => { + const x = tile % ATLAS_GRID * TILE_PX; + const y = Math.floor(tile / ATLAS_GRID) * TILE_PX; + ctx.save(); + ctx.translate(x, y); + ctx.beginPath(); + ctx.rect(0, 0, TILE_PX, TILE_PX); + ctx.clip(); + ctx.drawImage(image, 0, 0, TILE_PX, TILE_PX); + after?.(ctx); + ctx.restore(); + }; + if (images.wallpaper) { + blit(TILE.wallpaperA, images.wallpaper); + blit(TILE.wallpaperB, images.wallpaper, (c) => { + c.fillStyle = "rgba(96, 78, 30, 0.28)"; + c.fillRect(0, 0, TILE_PX, TILE_PX); + }); + } + if (images.ceiling) { + blit(TILE.ceiling, images.ceiling, (c) => { + c.strokeStyle = "rgba(140, 134, 116, 0.85)"; + c.lineWidth = 3; + for (let p = 0; p <= TILE_PX; p += TILE_PX / 2) { + c.beginPath(); + c.moveTo(p, 0); + c.lineTo(p, TILE_PX); + c.moveTo(0, p); + c.lineTo(TILE_PX, p); + c.stroke(); + } + }); + } + if (images.carpet) { + blit(TILE.carpet, images.carpet); + } + } + function buildAtlas() { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = ATLAS_GRID * TILE_PX; + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("2D canvas context unavailable; cannot build texture atlas"); + } + const painters = { + [TILE.wallpaperA]: paintWallpaperA, + [TILE.wallpaperB]: paintWallpaperB, + [TILE.ceiling]: paintCeiling, + [TILE.lightPanel]: paintLightPanel, + [TILE.carpet]: paintCarpet, + [TILE.concrete]: paintConcrete, + [TILE.paneling]: paintPaneling, + [TILE.drywall]: paintDrywall, + [TILE.ceramic]: paintCeramic, + [TILE.fabric]: paintFabric, + [TILE.metal]: paintMetal, + [TILE.wood]: paintWood, + [TILE.cardboard]: paintCardboard + }; + for (const [index, paint] of Object.entries(painters)) { + const i = Number(index); + const x = i % ATLAS_GRID * TILE_PX; + const y = Math.floor(i / ATLAS_GRID) * TILE_PX; + ctx.save(); + ctx.translate(x, y); + ctx.beginPath(); + ctx.rect(0, 0, TILE_PX, TILE_PX); + ctx.clip(); + paint(ctx); + ctx.restore(); + } + return canvas; + } + function grain(ctx, seed, amount) { + const image = ctx.getImageData(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(seed); + const data = image.data; + for (let i = 0; i < data.length; i += 4) { + const n = (rng.next() * 2 - 1) * amount; + data[i] = clampByte(data[i] + n); + data[i + 1] = clampByte(data[i + 1] + n); + data[i + 2] = clampByte(data[i + 2] + n); + } + const off = document.createElement("canvas"); + off.width = off.height = TILE_PX; + off.getContext("2d").putImageData(image, 0, 0); + ctx.drawImage(off, 0, 0); + } + function clampByte(v) { + return v < 0 ? 0 : v > 255 ? 255 : v; + } + function stains(ctx, seed, color, count) { + const rng = mulberry32(seed); + ctx.fillStyle = color; + for (let i = 0; i < count; i++) { + const cx = rng.next() * TILE_PX; + const cy = rng.next() * TILE_PX; + const r = 12 + rng.next() * 46; + ctx.globalAlpha = 0.04 + rng.next() * 0.07; + ctx.beginPath(); + ctx.ellipse(cx, cy, r, r * (0.5 + rng.next() * 0.8), rng.next() * Math.PI, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + } + function paintWallpaperA(ctx) { + ctx.fillStyle = "#c9b765"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "#b7a352"; + for (let x = 0; x < TILE_PX; x += 32) { + ctx.fillRect(x, 0, 14, TILE_PX); + } + ctx.fillStyle = "#a3914a"; + for (let x = 0; x < TILE_PX; x += 32) { + ctx.fillRect(x + 13, 0, 2, TILE_PX); + } + stains(ctx, 11, "#6f5f2c", 9); + grain(ctx, 12, 7); + } + function paintWallpaperB(ctx) { + ctx.fillStyle = "#c4b268"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "#ab984f"; + for (let y = 0; y < TILE_PX; y += 32) { + for (let x = 0; x < TILE_PX; x += 32) { + const ox = Math.floor(y / 32) % 2 * 16; + diamond(ctx, x + ox + 16, y + 16, 7); + } + } + stains(ctx, 21, "#6f5f2c", 7); + grain(ctx, 22, 6); + } + function diamond(ctx, cx, cy, r) { + ctx.beginPath(); + ctx.moveTo(cx, cy - r); + ctx.lineTo(cx + r, cy); + ctx.lineTo(cx, cy + r); + ctx.lineTo(cx - r, cy); + ctx.closePath(); + ctx.fill(); + } + function paintCeiling(ctx) { + ctx.fillStyle = "#d8d3c2"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(31); + ctx.fillStyle = "#b9b4a1"; + for (let i = 0; i < 2600; i++) { + ctx.fillRect(rng.next() * TILE_PX, rng.next() * TILE_PX, 1.5, 1.5); + } + ctx.strokeStyle = "#a09a87"; + ctx.lineWidth = 3; + for (let p = 0; p <= TILE_PX; p += 128) { + ctx.beginPath(); + ctx.moveTo(p, 0); + ctx.lineTo(p, TILE_PX); + ctx.moveTo(0, p); + ctx.lineTo(TILE_PX, p); + ctx.stroke(); + } + stains(ctx, 32, "#7c7452", 5); + grain(ctx, 33, 5); + } + function paintLightPanel(ctx) { + const g = ctx.createRadialGradient(128, 128, 20, 128, 128, 190); + g.addColorStop(0, "#fefadd"); + g.addColorStop(0.7, "#f8eeb4"); + g.addColorStop(1, "#e4d78d"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.strokeStyle = "rgba(190, 176, 110, 0.35)"; + ctx.lineWidth = 2; + for (let p = 0; p <= TILE_PX; p += 32) { + ctx.beginPath(); + ctx.moveTo(p, 0); + ctx.lineTo(p, TILE_PX); + ctx.moveTo(0, p); + ctx.lineTo(TILE_PX, p); + ctx.stroke(); + } + grain(ctx, 41, 3); + } + function paintCarpet(ctx) { + ctx.fillStyle = "#c2b47a"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(51); + for (let i = 0; i < 5200; i++) { + const x = rng.next() * TILE_PX; + const y = rng.next() * TILE_PX; + const shade = 150 + Math.floor(rng.next() * 60); + ctx.strokeStyle = `rgb(${shade}, ${shade - 14}, ${Math.floor(shade * 0.62)})`; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + (rng.next() * 4 - 2), y + (rng.next() * 4 - 2)); + ctx.stroke(); + } + stains(ctx, 52, "#5d5228", 8); + grain(ctx, 53, 6); + } + function paintConcrete(ctx) { + ctx.fillStyle = "#9a958a"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(61); + ctx.strokeStyle = "rgba(70, 66, 58, 0.5)"; + ctx.lineWidth = 1; + for (let i = 0; i < 5; i++) { + let x = rng.next() * TILE_PX; + let y = rng.next() * TILE_PX; + ctx.beginPath(); + ctx.moveTo(x, y); + for (let s = 0; s < 6; s++) { + x += rng.next() * 40 - 20; + y += rng.next() * 40 - 10; + ctx.lineTo(x, y); + } + ctx.stroke(); + } + stains(ctx, 62, "#4c483e", 6); + grain(ctx, 63, 10); + } + function paintPaneling(ctx) { + ctx.fillStyle = "#a8905e"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(71); + for (let x = 0; x < TILE_PX; x += 42) { + ctx.fillStyle = "#7d6741"; + ctx.fillRect(x, 0, 3, TILE_PX); + for (let i = 0; i < 22; i++) { + ctx.strokeStyle = `rgba(110, 88, 52, ${0.15 + rng.next() * 0.2})`; + const gx = x + 5 + rng.next() * 34; + ctx.beginPath(); + ctx.moveTo(gx, 0); + ctx.bezierCurveTo(gx + 4, 80, gx - 4, 170, gx + 2, TILE_PX); + ctx.stroke(); + } + } + grain(ctx, 72, 6); + } + function paintDrywall(ctx) { + ctx.fillStyle = "#cfc7ad"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + stains(ctx, 81, "#8d8465", 6); + grain(ctx, 82, 6); + } + function paintCeramic(ctx) { + ctx.fillStyle = "#b8b2a0"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "#d7d2c2"; + for (let y = 0; y < TILE_PX; y += 64) { + for (let x = 0; x < TILE_PX; x += 64) { + ctx.fillRect(x + 3, y + 3, 58, 58); + } + } + stains(ctx, 91, "#6d6752", 5); + grain(ctx, 92, 5); + } + function paintFabric(ctx) { + ctx.fillStyle = "#7a6f52"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + grain(ctx, 101, 12); + } + function paintMetal(ctx) { + ctx.fillStyle = "#8e9296"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(111); + for (let x = 0; x < TILE_PX; x += 2) { + ctx.fillStyle = `rgba(255, 255, 255, ${rng.next() * 0.06})`; + ctx.fillRect(x, 0, 1, TILE_PX); + } + grain(ctx, 112, 5); + } + function paintWood(ctx) { + ctx.fillStyle = "#8b6b43"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + const rng = mulberry32(121); + for (let i = 0; i < 30; i++) { + ctx.strokeStyle = `rgba(70, 50, 26, ${0.15 + rng.next() * 0.25})`; + const gy = rng.next() * TILE_PX; + ctx.beginPath(); + ctx.moveTo(0, gy); + ctx.bezierCurveTo(80, gy + 6, 170, gy - 6, TILE_PX, gy + 3); + ctx.stroke(); + } + grain(ctx, 122, 6); + } + function paintCardboard(ctx) { + ctx.fillStyle = "#b59a6b"; + ctx.fillRect(0, 0, TILE_PX, TILE_PX); + ctx.fillStyle = "rgba(214, 205, 175, 0.8)"; + ctx.fillRect(0, 108, TILE_PX, 40); + ctx.strokeStyle = "rgba(90, 72, 44, 0.6)"; + ctx.lineWidth = 2; + ctx.strokeRect(6, 6, TILE_PX - 12, TILE_PX - 12); + grain(ctx, 131, 8); + } + + // src/webview/renderer.ts + var FLOATS_PER_VERTEX = 10; + var EMISSIVE_SHADE = 2; + var VERTEX_SRC = ` +attribute vec3 aPosition; +attribute vec2 aUv; +attribute float aTile; +attribute vec3 aTint; +attribute float aShade; + +uniform mat4 uViewProj; +uniform vec3 uCamPos; + +varying vec2 vUv; +varying float vTile; +varying vec3 vTint; +varying float vShade; +varying float vDist; + +void main() { + vUv = aUv; + vTile = aTile; + vTint = aTint; + vShade = aShade; + vDist = distance(aPosition, uCamPos); + gl_Position = uViewProj * vec4(aPosition, 1.0); +} +`; + var FRAGMENT_SRC = ` +precision mediump float; + +uniform sampler2D uAtlas; +uniform vec3 uFogColor; +uniform float uFogDensity; +uniform float uFlicker; + +varying vec2 vUv; +varying float vTile; +varying vec3 vTint; +varying float vShade; +varying float vDist; + +const float GRID = ${ATLAS_GRID.toFixed(1)}; + +void main() { + float tile = floor(vTile + 0.5); + vec2 cell = vec2(mod(tile, GRID), floor(tile / GRID)); + // Half-texel inset keeps repeated tiles from bleeding across atlas seams. + vec2 local = fract(vUv) * (1.0 - 2.0 / 256.0) + 1.0 / 256.0; + vec4 tex = texture2D(uAtlas, (cell + local) / GRID); + + if (vShade >= ${EMISSIVE_SHADE.toFixed(1)} - 0.25) { + // Emissive light panel: flicker, no fog fade. + vec3 lit = tex.rgb * vTint * uFlicker * (vShade - 1.0); + float glowFog = 1.0 - exp(-vDist * vDist * uFogDensity * 0.35); + gl_FragColor = vec4(mix(lit, uFogColor, glowFog), 1.0); + return; + } + + vec3 color = tex.rgb * vTint * vShade * uFlicker; + float fog = 1.0 - exp(-vDist * vDist * uFogDensity); + gl_FragColor = vec4(mix(color, uFogColor, fog), 1.0); +} +`; + var FLOATS_PER_DECAL_VERTEX = 6; + var DECAL_VERTEX_SRC = ` +attribute vec3 aPosition; +attribute vec2 aUv; +attribute float aShade; + +uniform mat4 uViewProj; +uniform vec3 uCamPos; + +varying vec2 vUv; +varying float vShade; +varying float vDist; + +void main() { + vUv = aUv; + vShade = aShade; + vDist = distance(aPosition, uCamPos); + gl_Position = uViewProj * vec4(aPosition, 1.0); +} +`; + var DECAL_FRAGMENT_SRC = ` +precision mediump float; + +uniform sampler2D uTexture; +uniform float uFogDensity; +uniform float uFlicker; + +varying vec2 vUv; +varying float vShade; +varying float vDist; + +void main() { + vec4 tex = texture2D(uTexture, vUv); + float fog = 1.0 - exp(-vDist * vDist * uFogDensity); + // Ink dims with the wall lighting and dissolves into the fog. + gl_FragColor = vec4(tex.rgb * vShade * uFlicker, tex.a * (1.0 - fog)); +} +`; + var Renderer = class { + gl; + program; + uniforms; + attribs; + canvas; + fogColor = [0.055, 0.048, 0.02]; + fogDensity = 0.012; + // One rewritable mesh for animated geometry (the monster), rebuilt per frame. + dynVbo = null; + dynIbo = null; + dynCount = 0; + atlasCanvas; + atlasTexture; + // Wall-writing decals: separate program, texture, and mesh, rebuilt only + // when a new line is scrawled. + decalProgram; + decalUniforms; + decalAttribs; + decalTexture = null; + decalVbo = null; + decalIbo = null; + decalCount = 0; + constructor(canvas) { + this.canvas = canvas; + const gl = canvas.getContext("webgl", { antialias: true, alpha: false }); + if (!gl) { + throw new Error("WebGL is not available in this webview"); + } + this.gl = gl; + this.program = buildProgram(gl, VERTEX_SRC, FRAGMENT_SRC); + this.attribs = { + aPosition: gl.getAttribLocation(this.program, "aPosition"), + aUv: gl.getAttribLocation(this.program, "aUv"), + aTile: gl.getAttribLocation(this.program, "aTile"), + aTint: gl.getAttribLocation(this.program, "aTint"), + aShade: gl.getAttribLocation(this.program, "aShade") + }; + this.uniforms = {}; + for (const name of ["uViewProj", "uCamPos", "uAtlas", "uFogColor", "uFogDensity", "uFlicker"]) { + this.uniforms[name] = gl.getUniformLocation(this.program, name); + } + this.decalProgram = buildProgram(gl, DECAL_VERTEX_SRC, DECAL_FRAGMENT_SRC); + this.decalAttribs = { + aPosition: gl.getAttribLocation(this.decalProgram, "aPosition"), + aUv: gl.getAttribLocation(this.decalProgram, "aUv"), + aShade: gl.getAttribLocation(this.decalProgram, "aShade") + }; + this.decalUniforms = {}; + for (const name of ["uViewProj", "uCamPos", "uTexture", "uFogDensity", "uFlicker"]) { + this.decalUniforms[name] = gl.getUniformLocation(this.decalProgram, name); + } + this.atlasCanvas = buildAtlas(); + this.atlasTexture = gl.createTexture(); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.atlasCanvas); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.enable(gl.DEPTH_TEST); + gl.enable(gl.CULL_FACE); + gl.cullFace(gl.BACK); + } + /** Patches photo materials over the procedural atlas and re-uploads it. */ + applyMaterialImages(images) { + applyMaterialImages(this.atlasCanvas, images); + const gl = this.gl; + gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.atlasCanvas); + } + uploadChunk(vertices, indices) { + const gl = this.gl; + const vbo = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, vbo); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + const ibo = gl.createBuffer(); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW); + return { vbo, ibo, indexCount: indices.length }; + } + disposeChunk(mesh) { + this.gl.deleteBuffer(mesh.vbo); + this.gl.deleteBuffer(mesh.ibo); + } + /** Replaces the dynamic mesh drawn after the chunks this frame. */ + setDynamicMesh(vertices, indices) { + const gl = this.gl; + this.dynVbo ??= gl.createBuffer(); + this.dynIbo ??= gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.dynVbo); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.dynIbo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW); + this.dynCount = indices.length; + } + clearDynamicMesh() { + this.dynCount = 0; + } + /** + * Uploads (or re-uploads) the wall-writing texture from a canvas. + * Uses texture unit 1 to avoid disturbing the wall atlas on unit 0. + */ + setDecalTexture(source) { + const gl = this.gl; + this.decalTexture ??= gl.createTexture(); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.decalTexture); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.activeTexture(gl.TEXTURE0); + } + /** + * Replaces the decal mesh (vertex layout: position(3), uv(2), shade(1)). + * The decal pass draws wall writings as alpha-blended quads over the walls. + */ + setDecalMesh(vertices, indices) { + const gl = this.gl; + this.decalVbo ??= gl.createBuffer(); + this.decalIbo ??= gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.decalVbo); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.DYNAMIC_DRAW); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.decalIbo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.DYNAMIC_DRAW); + this.decalCount = indices.length; + } + clearDecalMesh() { + this.decalCount = 0; + } + resize() { + const dpr = Math.min(window.devicePixelRatio || 1, 2); + const w = Math.floor(this.canvas.clientWidth * dpr); + const h = Math.floor(this.canvas.clientHeight * dpr); + if (w > 0 && h > 0 && (this.canvas.width !== w || this.canvas.height !== h)) { + this.canvas.width = w; + this.canvas.height = h; + } + } + draw(chunks, camera, flicker) { + const gl = this.gl; + this.resize(); + gl.viewport(0, 0, this.canvas.width, this.canvas.height); + gl.clearColor(this.fogColor[0], this.fogColor[1], this.fogColor[2], 1); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + gl.useProgram(this.program); + const aspect = this.canvas.width / Math.max(1, this.canvas.height); + const viewProj = mat4Multiply( + mat4Perspective(camera.fovY, aspect, 0.02, 80), + mat4View(camera) + ); + gl.uniformMatrix4fv(this.uniforms.uViewProj, false, viewProj); + gl.uniform3f(this.uniforms.uCamPos, camera.x, camera.y, camera.z); + gl.uniform3f(this.uniforms.uFogColor, this.fogColor[0], this.fogColor[1], this.fogColor[2]); + gl.uniform1f(this.uniforms.uFogDensity, this.fogDensity); + gl.uniform1f(this.uniforms.uFlicker, flicker); + gl.uniform1i(this.uniforms.uAtlas, 0); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, this.atlasTexture); + for (const mesh of chunks) { + this.drawMesh(mesh.vbo, mesh.ibo, mesh.indexCount); + } + if (this.dynCount > 0 && this.dynVbo && this.dynIbo) { + this.drawMesh(this.dynVbo, this.dynIbo, this.dynCount); + } + this.drawDecals(viewProj, camera, flicker); + } + /** Alpha-blended wall-writing pass, drawn over the opaque geometry. */ + drawDecals(viewProj, camera, flicker) { + if (this.decalCount === 0 || !this.decalVbo || !this.decalIbo || !this.decalTexture) { + return; + } + const gl = this.gl; + gl.useProgram(this.decalProgram); + gl.uniformMatrix4fv(this.decalUniforms.uViewProj, false, viewProj); + gl.uniform3f(this.decalUniforms.uCamPos, camera.x, camera.y, camera.z); + gl.uniform1f(this.decalUniforms.uFogDensity, this.fogDensity); + gl.uniform1f(this.decalUniforms.uFlicker, flicker); + gl.uniform1i(this.decalUniforms.uTexture, 1); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.decalTexture); + gl.activeTexture(gl.TEXTURE0); + gl.enable(gl.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + gl.depthMask(false); + for (const a of Object.values(this.attribs)) { + gl.disableVertexAttribArray(a); + } + const stride = FLOATS_PER_DECAL_VERTEX * 4; + gl.bindBuffer(gl.ARRAY_BUFFER, this.decalVbo); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.decalIbo); + gl.vertexAttribPointer(this.decalAttribs.aPosition, 3, gl.FLOAT, false, stride, 0); + gl.vertexAttribPointer(this.decalAttribs.aUv, 2, gl.FLOAT, false, stride, 12); + gl.vertexAttribPointer(this.decalAttribs.aShade, 1, gl.FLOAT, false, stride, 20); + for (const a of Object.values(this.decalAttribs)) { + gl.enableVertexAttribArray(a); + } + gl.drawElements(gl.TRIANGLES, this.decalCount, gl.UNSIGNED_SHORT, 0); + for (const a of Object.values(this.decalAttribs)) { + gl.disableVertexAttribArray(a); + } + gl.depthMask(true); + gl.disable(gl.BLEND); + } + drawMesh(vbo, ibo, indexCount) { + const gl = this.gl; + const stride = FLOATS_PER_VERTEX * 4; + gl.bindBuffer(gl.ARRAY_BUFFER, vbo); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, ibo); + gl.vertexAttribPointer(this.attribs.aPosition, 3, gl.FLOAT, false, stride, 0); + gl.vertexAttribPointer(this.attribs.aUv, 2, gl.FLOAT, false, stride, 12); + gl.vertexAttribPointer(this.attribs.aTile, 1, gl.FLOAT, false, stride, 20); + gl.vertexAttribPointer(this.attribs.aTint, 3, gl.FLOAT, false, stride, 24); + gl.vertexAttribPointer(this.attribs.aShade, 1, gl.FLOAT, false, stride, 36); + for (const a of Object.values(this.attribs)) { + gl.enableVertexAttribArray(a); + } + gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0); + } + }; + function buildProgram(gl, vsSrc, fsSrc) { + const compile = (type, src) => { + const shader = gl.createShader(type); + gl.shaderSource(shader, src); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + throw new Error(`Shader compile failed: ${gl.getShaderInfoLog(shader) ?? "unknown"}`); + } + return shader; + }; + const program = gl.createProgram(); + gl.attachShader(program, compile(gl.VERTEX_SHADER, vsSrc)); + gl.attachShader(program, compile(gl.FRAGMENT_SHADER, fsSrc)); + gl.linkProgram(program); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + throw new Error(`Program link failed: ${gl.getProgramInfoLog(program) ?? "unknown"}`); + } + return program; + } + function mat4Perspective(fovY, aspect, near, far) { + const f = 1 / Math.tan(fovY / 2); + const nf = 1 / (near - far); + const out = new Float32Array(16); + out[0] = f / aspect; + out[5] = f; + out[10] = (far + near) * nf; + out[11] = -1; + out[14] = 2 * far * near * nf; + return out; + } + function mat4View(camera) { + const rot = mat4Multiply( + mat4RotateZ(-camera.roll), + mat4Multiply(mat4RotateX(-camera.pitch), mat4RotateY(-camera.yaw)) + ); + const trans = mat4Identity(); + trans[12] = -camera.x; + trans[13] = -camera.y; + trans[14] = -camera.z; + return mat4Multiply(rot, trans); + } + function mat4Identity() { + const out = new Float32Array(16); + out[0] = out[5] = out[10] = out[15] = 1; + return out; + } + function mat4RotateX(rad) { + const out = mat4Identity(); + const c = Math.cos(rad); + const s = Math.sin(rad); + out[5] = c; + out[6] = s; + out[9] = -s; + out[10] = c; + return out; + } + function mat4RotateY(rad) { + const out = mat4Identity(); + const c = Math.cos(rad); + const s = Math.sin(rad); + out[0] = c; + out[2] = -s; + out[8] = s; + out[10] = c; + return out; + } + function mat4RotateZ(rad) { + const out = mat4Identity(); + const c = Math.cos(rad); + const s = Math.sin(rad); + out[0] = c; + out[1] = s; + out[4] = -s; + out[5] = c; + return out; + } + function mat4Multiply(a, b) { + const out = new Float32Array(16); + for (let col = 0; col < 4; col++) { + for (let row = 0; row < 4; row++) { + let sum = 0; + for (let k = 0; k < 4; k++) { + sum += a[k * 4 + row] * b[col * 4 + k]; + } + out[col * 4 + row] = sum; + } + } + return out; + } + + // src/webview/world.ts + var DEPTH_RANGE = { min: 1.1, max: 1.6 }; + var PLAYER_RADIUS = 0.24; + var WALL_HALF_DEPTH = 0.06; + var DOOR_TOP_FRACTION = 0.84; + var CHUNK_SIZE = 4; + var LIGHT_LATTICE = 3; + var ZONE_LATTICE = 12; + var SALT2 = { + lightState: 7001, + zoneAnchor: 7101, + zoneCenterX: 7102, + zoneCenterY: 7103, + zoneRadius: 7104, + zonePhase1: 7105, + zonePhase2: 7106, + zonePalette: 7107, + zoneVariant: 7108 + }; + var WALL_TILE_BY_SURFACE = { + drywall: TILE.drywall, + wallpaper: TILE.wallpaperA, + paneling: TILE.paneling, + concrete: TILE.concrete, + tile: TILE.ceramic, + carpet: TILE.carpet + }; + var MATERIAL_PRESETS = { + classic: { tile: null, tint: [1, 1, 1] }, + office: { tile: TILE.drywall, tint: [1, 1, 1] }, + pool: { tile: TILE.ceramic, tint: [0.88, 1, 1.06] }, + concrete: { tile: TILE.concrete, tint: [1, 1, 1] }, + panel: { tile: TILE.paneling, tint: [1, 1, 1] } + }; + var ZONE_PALETTES = [ + [1.1, 0.6, 0.52], + // faded rose + [0.58, 0.95, 0.38], + // mossy green + [0.5, 0.72, 1.1], + // dusty blue + [0.72, 0.5, 0.26], + // deep sepia + [1.35, 1.32, 1.12] + // bleached bone + ]; + var WHITE = [1, 1, 1]; + var World = class { + generator; + session; + seed; + furnitureEnabled = true; + // Off by default, matching DEFAULT_SETTINGS.wallpaperShifts. + wallpaperShiftsEnabled = false; + /** + * When a photo wallpaper is loaded, the classic preset papers every wall + * with it instead of the generator's mixed surface types; per-edge wear and + * wallpaper zones still provide the variation. + */ + uniformWallpaper = false; + wallOverrideTile = null; + wallBaseTint = [1, 1, 1]; + cellsVisited = 1; + zoneCache = /* @__PURE__ */ new Map(); + chunks = /* @__PURE__ */ new Map(); + constructor(seed) { + this.seed = seed; + this.generator = new MazeGenerator({ + seed, + depth: DEPTH_RANGE, + propFrequency: 0.14 + }); + this.session = new MazeSession(this.generator); + this.session.on("enterCell", () => { + this.cellsVisited++; + }); + } + /** + * Applies a wall material preset plus its adjustable elements (hue rotation + * in degrees and a brightness multiplier). Call invalidateChunks afterwards + * so existing meshes pick the change up. + * + * The preset determines the base tile and tint for all walls. Hue rotation + * lets players shift the wallpaper color, and brightness scales the overall + * material lightness without destroying the wear variation. + */ + setMaterial(preset, hueShiftDeg, brightness) { + const base = MATERIAL_PRESETS[preset] ?? MATERIAL_PRESETS.classic; + this.wallOverrideTile = base.tile; + this.wallBaseTint = hueRotate(base.tint, hueShiftDeg).map( + (v) => Math.max(0, v * brightness) + ); + } + /** Public light sample for decorations drawn outside the chunk mesher. */ + lightAt(x, y) { + return this.lightLevelAt(x, y); + } + stats() { + return { + seed: this.seed, + cellsVisited: this.cellsVisited, + cacheSize: this.generator.stats().size + }; + } + /** + * Keeps the discrete MazeSession in step with the continuous player + * position, so its move/enterCell events stay meaningful. + * + * When the player crosses a cell boundary, this tries to move the session + * in the matching direction. If that fails (shouldn't happen - walls block + * both), it warps the session to match reality. + */ + syncSession(px, py) { + const cx = Math.floor(px); + const cy = Math.floor(py); + const at = this.session.player; + if (at.cx === cx && at.cy === cy) { + return; + } + const dx = cx - at.cx; + const dy = cy - at.cy; + if (Math.abs(dx) + Math.abs(dy) === 1) { + const direction = dx === 1 ? "east" : dx === -1 ? "west" : dy === 1 ? "south" : "north"; + if (!this.session.move(direction)) { + this.session.warpTo(cx, cy); + } + } else { + this.session.warpTo(cx, cy); + } + } + // --- Movement ------------------------------------------------------------ + /** + * Moves the player from (px, py) toward (px+dx, py+dy) in plane coordinates, + * resolving collisions per axis so the player slides along walls. + * + * This implements a sweep-and-clamp collision resolver: each axis is tested + * independently. If the desired position is blocked, the coordinate is + * clamped to the nearest wall surface (within the motion delta to avoid + * teleport-like jumps). This creates smooth wall-sliding behavior common in + * first-person games. + */ + moveResolved(px, py, dx, dy) { + let x = px; + let y = py; + const withinStep = (from, to, v) => v >= Math.min(from, to) - 1e-9 && v <= Math.max(from, to) + 1e-9; + const tryAxis = (nx, ny, axis) => { + if (this.canOccupy(nx, ny)) { + x = nx; + y = ny; + return; + } + if (axis === "x") { + const cx = Math.floor(x); + const clamped = nx > x ? cx + 1 - PLAYER_RADIUS - 1e-4 : cx + PLAYER_RADIUS + 1e-4; + if (withinStep(x, nx, clamped) && this.canOccupy(clamped, ny)) { + x = clamped; + y = ny; + } + } else { + const cy = Math.floor(y); + const clamped = ny > y ? cy + 1 - PLAYER_RADIUS - 1e-4 : cy + PLAYER_RADIUS + 1e-4; + if (withinStep(y, ny, clamped) && this.canOccupy(nx, clamped)) { + x = nx; + y = clamped; + } + } + }; + const steps = Math.max(1, Math.ceil(Math.max(Math.abs(dx), Math.abs(dy)) / PLAYER_RADIUS)); + for (let i = 0; i < steps; i++) { + tryAxis(x + dx / steps, y, "x"); + tryAxis(x, y + dy / steps, "y"); + } + return { x, y }; + } + /** + * Whether a player disc at (x, y) fits: each corner of its bounding square + * must be reachable from the center cell through open edges only. + * + * This implements circle-vs-grid collision by checking the four corners of + * the circle's bounding box. Each corner cell must be connected to the + * player's center cell via a valid path of open edges (either directly if + * they're adjacent, or through an intermediate cell at a diagonal). + */ + canOccupy(x, y) { + const cx = Math.floor(x); + const cy = Math.floor(y); + for (const ox of [-PLAYER_RADIUS, PLAYER_RADIUS]) { + for (const oy of [-PLAYER_RADIUS, PLAYER_RADIUS]) { + const ccx = Math.floor(x + ox); + const ccy = Math.floor(y + oy); + if (ccx === cx && ccy === cy) { + continue; + } + const dirX = ccx > cx ? "east" : ccx < cx ? "west" : null; + const dirY = ccy > cy ? "south" : ccy < cy ? "north" : null; + if (dirX && !dirY) { + if (!this.generator.isPassable(cx, cy, dirX)) { + return false; + } + } else if (dirY && !dirX) { + if (!this.generator.isPassable(cx, cy, dirY)) { + return false; + } + } else if (dirX && dirY) { + const viaX = this.generator.isPassable(cx, cy, dirX) && this.generator.isPassable(ccx, cy, dirY); + const viaY = this.generator.isPassable(cx, cy, dirY) && this.generator.isPassable(cx, ccy, dirX); + if (!viaX && !viaY) { + return false; + } + } + } + } + return true; + } + // --- Lights ---------------------------------------------------------------- + /** + * Determines light state for a cell: on, dead, or flickering. + * Lights live on a 3-cell lattice (offset to [1,1] within the pattern). + * Returns null for cells that have no light fixture. + * + * Dead lights (12% chance) never illuminate. Flickering lights (8% chance) + * pulse erratically. The rest stay on with the global flicker hum. + */ + lightState(cx, cy) { + const mod = (n, m) => (n % m + m) % m; + if (mod(cx, LIGHT_LATTICE) !== 1 || mod(cy, LIGHT_LATTICE) !== 1) { + return null; + } + const u = unitFromHash(hashCoords(this.seed, cx, cy, SALT2.lightState)); + if (u < 0.12) { + return "dead"; + } + if (u < 0.2) { + return "flicker"; + } + return "on"; + } + /** Summed light contribution at a plane point, in [0, 1]. */ + lightLevelAt(x, y) { + const reach = 3.2; + let level = 0; + const minGx = Math.floor((x - reach) / LIGHT_LATTICE); + const maxGx = Math.floor((x + reach) / LIGHT_LATTICE); + const minGy = Math.floor((y - reach) / LIGHT_LATTICE); + const maxGy = Math.floor((y + reach) / LIGHT_LATTICE); + for (let gy = minGy; gy <= maxGy; gy++) { + for (let gx = minGx; gx <= maxGx; gx++) { + const lcx = gx * LIGHT_LATTICE + 1; + const lcy = gy * LIGHT_LATTICE + 1; + const state = this.lightState(lcx, lcy); + if (state !== "on" && state !== "flicker") { + continue; + } + const dx = x - (lcx + 0.5); + const dy = y - (lcy + 0.5); + const d = Math.sqrt(dx * dx + dy * dy); + if (d < reach) { + const fall = 1 - d / reach; + level += fall * fall * (state === "flicker" ? 0.55 : 1); + } + } + } + return Math.min(1, level); + } + // --- Wallpaper zones ------------------------------------------------------- + /** + * Random enclosed shapes: anchors on a coarse lattice each spawn a wobbled + * closed radial blob (radius modulated by two sine harmonics with hashed + * phases). A cell inside a blob adopts that zone's palette; the innermost + * blob wins where blobs overlap. + */ + zoneAt(cx, cy) { + if (!this.wallpaperShiftsEnabled) { + return null; + } + const key = `${cx},${cy}`; + const cached = this.zoneCache.get(key); + if (cached !== void 0) { + return cached; + } + if (this.zoneCache.size > 2e4) { + this.zoneCache.clear(); + } + const gx0 = Math.floor(cx / ZONE_LATTICE); + const gy0 = Math.floor(cy / ZONE_LATTICE); + let best = null; + for (let gy = gy0 - 1; gy <= gy0 + 1; gy++) { + for (let gx = gx0 - 1; gx <= gx0 + 1; gx++) { + const draw = (salt) => unitFromHash(hashCoords(this.seed, gx, gy, salt)); + if (draw(SALT2.zoneAnchor) >= 0.45) { + continue; + } + const centerX = (gx + draw(SALT2.zoneCenterX)) * ZONE_LATTICE; + const centerY = (gy + draw(SALT2.zoneCenterY)) * ZONE_LATTICE; + const base = 3 + draw(SALT2.zoneRadius) * 5; + const dx = cx + 0.5 - centerX; + const dy = cy + 0.5 - centerY; + const dist = Math.sqrt(dx * dx + dy * dy); + const theta = Math.atan2(dy, dx); + const p1 = draw(SALT2.zonePhase1) * Math.PI * 2; + const p2 = draw(SALT2.zonePhase2) * Math.PI * 2; + const radius = base * (1 + 0.3 * Math.sin(3 * theta + p1) + 0.18 * Math.sin(5 * theta + p2)); + if (dist >= radius) { + continue; + } + const depth = dist / radius; + if (!best || depth < best.depth) { + const palette = ZONE_PALETTES[Math.floor(draw(SALT2.zonePalette) * ZONE_PALETTES.length)] ?? WHITE; + best = { + depth, + zone: { tint: [...palette], variant: draw(SALT2.zoneVariant) < 0.5 } + }; + } + } + } + const zone = best?.zone ?? null; + this.zoneCache.set(key, zone); + return zone; + } + // --- Chunk streaming ------------------------------------------------------- + /** + * Ensures every chunk within the render distance is meshed and uploaded, + * dropping chunks that fell out of range. Returns the drawable set. + */ + updateChunks(px, py, renderDistance, renderer) { + const range = renderDistance + CHUNK_SIZE; + const minCx = Math.floor((px - range) / CHUNK_SIZE); + const maxCx = Math.floor((px + range) / CHUNK_SIZE); + const minCy = Math.floor((py - range) / CHUNK_SIZE); + const maxCy = Math.floor((py + range) / CHUNK_SIZE); + const wanted = /* @__PURE__ */ new Set(); + for (let gy = minCy; gy <= maxCy; gy++) { + for (let gx = minCx; gx <= maxCx; gx++) { + const centerX = (gx + 0.5) * CHUNK_SIZE; + const centerY = (gy + 0.5) * CHUNK_SIZE; + const dist = Math.hypot(centerX - px, centerY - py); + if (dist > renderDistance + CHUNK_SIZE) { + continue; + } + const key = `${gx},${gy}`; + wanted.add(key); + if (!this.chunks.has(key)) { + this.chunks.set(key, this.buildChunk(gx, gy, renderer)); + } + } + } + for (const [key, mesh] of this.chunks) { + if (!wanted.has(key)) { + renderer.disposeChunk(mesh); + this.chunks.delete(key); + } + } + return this.chunks.values(); + } + /** Drops all uploaded chunks (e.g. when toggling furniture or zones). */ + invalidateChunks(renderer) { + for (const mesh of this.chunks.values()) { + renderer.disposeChunk(mesh); + } + this.chunks.clear(); + this.zoneCache.clear(); + } + buildChunk(gx, gy, renderer) { + const builder = new MeshBuilder(); + for (let cy = gy * CHUNK_SIZE; cy < (gy + 1) * CHUNK_SIZE; cy++) { + for (let cx = gx * CHUNK_SIZE; cx < (gx + 1) * CHUNK_SIZE; cx++) { + this.emitCell(builder, this.generator.getCell(cx, cy)); + } + } + return renderer.uploadChunk(builder.vertices(), builder.indices()); + } + emitCell(b, cell) { + const { cx, cy } = cell; + const x0 = cell.bounds.min.x; + const y0 = cell.bounds.min.y; + const x1 = cell.bounds.max.x; + const y1 = cell.bounds.max.y; + const h = cell.dimensions.depth; + const zone = this.zoneAt(cx, cy); + const shadeAt = (x, y, base, span) => base + span * this.lightLevelAt(x, y); + const floorTint = zone ? [mix(1, zone.tint[0], 0.25), mix(1, zone.tint[1], 0.25), mix(1, zone.tint[2], 0.25)] : WHITE; + b.quad( + [x0, 0, y0], + [x0, 0, y1], + [x1, 0, y1], + [x1, 0, y0], + [x0, y0], + [x0, y1], + [x1, y1], + [x1, y0], + TILE.carpet, + floorTint, + [ + shadeAt(x0, y0, 0.5, 0.55), + shadeAt(x0, y1, 0.5, 0.55), + shadeAt(x1, y1, 0.5, 0.55), + shadeAt(x1, y0, 0.5, 0.55) + ] + ); + b.quad( + [x0, h, y0], + [x1, h, y0], + [x1, h, y1], + [x0, h, y1], + [x0 * 2, y0 * 2], + [x1 * 2, y0 * 2], + [x1 * 2, y1 * 2], + [x0 * 2, y1 * 2], + TILE.ceiling, + WHITE, + [ + shadeAt(x0, y0, 0.38, 0.4), + shadeAt(x1, y0, 0.38, 0.4), + shadeAt(x1, y1, 0.38, 0.4), + shadeAt(x0, y1, 0.38, 0.4) + ] + ); + const light = this.lightState(cx, cy); + if (light) { + const inset = 0.24; + const drop = 0.02; + const lx0 = x0 + inset; + const lx1 = x1 - inset; + const ly0 = y0 + inset; + const ly1 = y1 - inset; + const py = h - drop; + const shade = light === "dead" ? 0.32 : light === "flicker" ? EMISSIVE_SHADE + 0.35 : EMISSIVE_SHADE + 0.6; + b.quad( + [lx0, py, ly0], + [lx1, py, ly0], + [lx1, py, ly1], + [lx0, py, ly1], + [0, 0], + [1, 0], + [1, 1], + [0, 1], + TILE.lightPanel, + WHITE, + [shade, shade, shade, shade] + ); + } + for (const direction of DIRECTIONS) { + if (cell.edges[direction].solid) { + this.emitWallSlab(b, cell, direction, zone); + } else { + this.emitDoorHeader(b, cell, direction, zone); + } + } + if (cell.feature && this.furnitureEnabled) { + this.emitFeature(b, cell, cell.feature); + } + } + /** Resolves the tile, tint, and wear factor for one wall edge. */ + wallMaterial(edge, zone) { + let tile; + if (this.wallOverrideTile !== null) { + tile = this.wallOverrideTile; + } else if (this.uniformWallpaper) { + tile = TILE.wallpaperA; + } else { + tile = WALL_TILE_BY_SURFACE[edge.metadata.surfaceType]; + } + let tint = this.wallBaseTint; + if (zone && (this.wallOverrideTile !== null || tile === TILE.wallpaperA)) { + tint = [tint[0] * zone.tint[0], tint[1] * zone.tint[1], tint[2] * zone.tint[2]]; + if (zone.variant && tile === TILE.wallpaperA) { + tile = TILE.wallpaperB; + } + } + const wear = 0.92 + unitFromHash(edge.metadata.materialSeed) * 0.12; + return { tile, tint, wear }; + } + /** + * A solid edge as a slab: the inward face is inset by WALL_HALF_DEPTH (the + * neighbor emits the matching opposite face), and any end where the wall + * line stops at an open passage gets a jamb cap sealing the slab depth. + */ + emitWallSlab(b, cell, direction, zone) { + const h = cell.dimensions.depth; + this.emitWallFace(b, cell, direction, 0, h, zone); + const edge = cell.edges[direction]; + const inward = step(opposite(direction)); + const mat = this.wallMaterial(edge, zone); + for (const end of [edge.start, edge.end]) { + const other = end === edge.start ? edge.end : edge.start; + const ox = Math.sign(end.x - other.x); + const oy = Math.sign(end.y - other.y); + if (this.generator.isPassable(cell.cx + ox, cell.cy + oy, direction)) { + this.emitJamb(b, end, { ox, oy }, inward, 0, h, mat); + } + } + } + /** + * A vertical cap strip sealing this cell's half of a wall slab at a wall + * end, facing out of the wall along `out`. + */ + emitJamb(b, at, out, inward, yBottom, yTop, mat) { + let ax = at.x; + let az = at.y; + let bx = at.x + inward.dx * WALL_HALF_DEPTH; + let bz = at.y + inward.dy * WALL_HALF_DEPTH; + if (-(bz - az) * out.ox + (bx - ax) * out.oy < 0) { + [ax, bx] = [bx, ax]; + [az, bz] = [bz, az]; + } + const shade = (0.42 + 0.5 * this.lightLevelAt(at.x + out.ox * 0.2, at.y + out.oy * 0.2)) * mat.wear * 0.82; + const u0 = ax + az; + const u1 = bx + bz; + b.quad( + [ax, yBottom, az], + [bx, yBottom, bz], + [bx, yTop, bz], + [ax, yTop, az], + [u0, yBottom / 1.6], + [u1, yBottom / 1.6], + [u1, yTop / 1.6], + [u0, yTop / 1.6], + mat.tile, + mat.tint, + [shade, shade, shade * 0.92, shade * 0.92] + ); + } + /** + * Header over an open edge. An opening whose wall line is solid on both + * flanks reads as a doorway punched through a wall, so it gets a lintel: + * face down to DOOR_TOP_FRACTION of the lower ceiling, a soffit underside, + * and end caps. Interior edges of merged open areas (room/hall/atrium + * pairs) only get a soffit band where the neighbor's ceiling steps down. + */ + emitDoorHeader(b, cell, direction, zone) { + const h = cell.dimensions.depth; + const { dx, dy } = step(direction); + const ncx = cell.cx + dx; + const ncy = cell.cy + dy; + const nh = this.generator.getCell(ncx, ncy).dimensions.depth; + const gen = this.generator; + const interior = gen.isRoom(cell.cx, cell.cy) && gen.isRoom(ncx, ncy) || gen.isHall(cell.cx, cell.cy) && gen.isHall(ncx, ncy) || gen.isAtrium(cell.cx, cell.cy) && gen.isAtrium(ncx, ncy); + let bottom = null; + if (!interior) { + const edge2 = cell.edges[direction]; + const flanks = [edge2.start, edge2.end].map((end) => { + const other = end === edge2.start ? edge2.end : edge2.start; + const ox = Math.sign(end.x - other.x); + const oy = Math.sign(end.y - other.y); + return !gen.isPassable(cell.cx + ox, cell.cy + oy, direction); + }); + if (flanks[0] && flanks[1]) { + bottom = Math.min(h, nh) * DOOR_TOP_FRACTION; + } + } + if (bottom === null && nh < h - 0.01) { + bottom = nh; + } + if (bottom === null || bottom >= h - 5e-3) { + return; + } + this.emitWallFace(b, cell, direction, bottom, h, zone); + const edge = cell.edges[direction]; + const inward = step(opposite(direction)); + const mat = this.wallMaterial(edge, zone); + const T = WALL_HALF_DEPTH; + let sx = edge.start.x; + let sz = edge.start.y; + let ex = edge.end.x; + let ez = edge.end.y; + if ((ez - sz) * inward.dx - (ex - sx) * inward.dy > 0) { + [sx, ex] = [ex, sx]; + [sz, ez] = [ez, sz]; + } + const soffitShade = (0.36 + 0.4 * this.lightLevelAt((sx + ex) / 2, (sz + ez) / 2)) * mat.wear; + b.quad( + [sx, bottom, sz], + [ex, bottom, ez], + [ex + inward.dx * T, bottom, ez + inward.dy * T], + [sx + inward.dx * T, bottom, sz + inward.dy * T], + [sx + sz, 0], + [ex + ez, 0], + [ex + ez, T / 1.6], + [sx + sz, T / 1.6], + mat.tile, + mat.tint, + [soffitShade, soffitShade, soffitShade, soffitShade] + ); + for (const end of [edge.start, edge.end]) { + const other = end === edge.start ? edge.end : edge.start; + const ox = Math.sign(end.x - other.x); + const oy = Math.sign(end.y - other.y); + this.emitJamb(b, end, { ox, oy }, inward, bottom, h, mat); + } + } + /** + * The inward-facing wall face for an edge, inset WALL_HALF_DEPTH into the + * cell so the slab has visible extrusion depth at openings. + */ + emitWallFace(b, cell, direction, yBottom, yTop, zone) { + const edge = cell.edges[direction]; + const inward = step(opposite(direction)); + const T = WALL_HALF_DEPTH; + let sx = edge.start.x + inward.dx * T; + let sz = edge.start.y + inward.dy * T; + let ex = edge.end.x + inward.dx * T; + let ez = edge.end.y + inward.dy * T; + const normalX = -(ez - sz); + const normalZ = ex - sx; + if (normalX * inward.dx + normalZ * inward.dy < 0) { + [sx, ex] = [ex, sx]; + [sz, ez] = [ez, sz]; + } + const { tile, tint, wear } = this.wallMaterial(edge, zone); + const u0 = sx + sz; + const u1 = ex + ez; + const v0 = yBottom / 1.6; + const v1 = yTop / 1.6; + const sample = (x, z) => (0.42 + 0.5 * this.lightLevelAt(x, z)) * wear; + const sS = sample(sx + inward.dx * 0.2, sz + inward.dy * 0.2); + const sE = sample(ex + inward.dx * 0.2, ez + inward.dy * 0.2); + b.quad( + [sx, yBottom, sz], + [ex, yBottom, ez], + [ex, yTop, ez], + [sx, yTop, sz], + [u0, v0], + [u1, v0], + [u1, v1], + [u0, v1], + tile, + tint, + [sS, sE, sE * 0.92, sS * 0.92] + ); + } + emitFeature(b, cell, feature) { + const fx = feature.position.x; + const fz = feature.position.y; + const h = cell.dimensions.depth; + const light = 0.4 + 0.5 * this.lightLevelAt(fx, fz); + if (feature.kind === "column") { + const r = Math.max(0.09, feature.size); + emitBox(b, fx - r, fx + r, 0, h, fz - r, fz + r, TILE.drywall, WHITE, light); + return; + } + const rng = mulberry32(feature.variantSeed); + const scale = clamp2(feature.size / 0.15, 0.75, 1.35); + const archetype = Math.floor(rng.next() * 4); + const s = (v) => v * scale; + switch (archetype) { + case 0: { + emitBox(b, fx - s(0.14), fx + s(0.14), 0, s(0.52), fz - s(0.12), fz + s(0.12), TILE.metal, WHITE, light); + break; + } + case 1: { + const w = s(0.32); + const d = s(0.2); + const top = s(0.3); + emitBox(b, fx - w, fx - w + s(0.04), 0, top, fz - d, fz + d, TILE.wood, WHITE, light * 0.9); + emitBox(b, fx + w - s(0.04), fx + w, 0, top, fz - d, fz + d, TILE.wood, WHITE, light * 0.9); + emitBox(b, fx - w, fx + w, top, top + s(0.04), fz - d, fz + d, TILE.wood, WHITE, light); + break; + } + case 2: { + const w = s(0.36); + const d = s(0.17); + emitBox(b, fx - w, fx + w, 0, s(0.18), fz - d, fz + d, TILE.fabric, WHITE, light); + emitBox(b, fx - w, fx + w, s(0.18), s(0.4), fz + d - s(0.07), fz + d, TILE.fabric, WHITE, light * 0.95); + break; + } + default: { + const r = s(0.17); + emitBox(b, fx - r, fx + r, 0, s(0.26), fz - r, fz + r, TILE.cardboard, WHITE, light); + const r2 = s(0.12); + const ox = (rng.next() - 0.5) * s(0.08); + const oz = (rng.next() - 0.5) * s(0.08); + emitBox(b, fx - r2 + ox, fx + r2 + ox, s(0.26), s(0.46), fz - r2 + oz, fz + r2 + oz, TILE.cardboard, WHITE, light * 1.05); + break; + } + } + } + }; + function emitBox(b, x0, x1, y0, y1, z0, z1, tile, tint, shade) { + const sides = shade * 0.85; + const uw = (x1 - x0) * 2; + const ud = (z1 - z0) * 2; + const vh = (y1 - y0) * 2; + b.quad( + [x0, y0, z1], + [x1, y0, z1], + [x1, y1, z1], + [x0, y1, z1], + [0, 0], + [uw, 0], + [uw, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x1, y0, z0], + [x0, y0, z0], + [x0, y1, z0], + [x1, y1, z0], + [0, 0], + [uw, 0], + [uw, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x1, y0, z1], + [x1, y0, z0], + [x1, y1, z0], + [x1, y1, z1], + [0, 0], + [ud, 0], + [ud, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x0, y0, z0], + [x0, y0, z1], + [x0, y1, z1], + [x0, y1, z0], + [0, 0], + [ud, 0], + [ud, vh], + [0, vh], + tile, + tint, + [sides, sides, sides, sides] + ); + b.quad( + [x0, y1, z0], + [x0, y1, z1], + [x1, y1, z1], + [x1, y1, z0], + [0, 0], + [0, ud], + [uw, ud], + [uw, 0], + tile, + tint, + [shade, shade, shade, shade] + ); + } + function mix(a, b, t) { + return a + (b - a) * t; + } + function hueRotate(rgb, degrees) { + const rad = degrees * Math.PI / 180; + const c = Math.cos(rad); + const s = Math.sin(rad); + const [r, g, b] = rgb; + return [ + (0.213 + c * 0.787 - s * 0.213) * r + (0.715 - c * 0.715 - s * 0.715) * g + (0.072 - c * 0.072 + s * 0.928) * b, + (0.213 - c * 0.213 + s * 0.143) * r + (0.715 + c * 0.285 + s * 0.14) * g + (0.072 - c * 0.072 - s * 0.283) * b, + (0.213 - c * 0.213 - s * 0.787) * r + (0.715 - c * 0.715 + s * 0.715) * g + (0.072 + c * 0.928 + s * 0.072) * b + ]; + } + function clamp2(v, lo, hi) { + return Math.min(hi, Math.max(lo, v)); + } + var MeshBuilder = class { + verts = []; + idx = []; + count = 0; + quad(p0, p1, p2, p3, t0, t1, t2, t3, tile, tint, shades) { + const points = [p0, p1, p2, p3]; + const uvs = [t0, t1, t2, t3]; + for (let i = 0; i < 4; i++) { + const p = points[i]; + const t = uvs[i]; + this.verts.push(p[0], p[1], p[2], t[0], t[1], tile, tint[0], tint[1], tint[2], shades[i]); + } + const base = this.count; + this.idx.push(base, base + 1, base + 2, base, base + 2, base + 3); + this.count += 4; + } + vertices() { + return new Float32Array(this.verts); + } + indices() { + if (this.count > 65535) { + throw new Error(`Chunk exceeds 16-bit index range: ${this.count} vertices`); + } + return new Uint16Array(this.idx); + } + }; + + // src/webview/graffiti.ts + var ATLAS_SIZE = 1024; + var SLOT_W = 512; + var SLOT_H = 256; + var SLOT_COLS = 2; + var STATIC_SLOTS = 7; + var LIVE_SLOT = 7; + var MAX_WRITE_DISTANCE = 6; + var RESTAMP_DISTANCE = 4; + var STAMP_COOLDOWN_MS = 2500; + var HISTORY_COOLDOWN_MS = 1200; + var REVEAL_CPS = 22; + var LIVE_REDRAW_MS = 90; + var GHOST_TAIL = 3; + var SEGMENT_CAP = 120; + var MIN_COMMIT_CHARS = 12; + var WALL_SWITCH_MS = 700; + var WRITE_Y0 = 0.55; + var WRITE_Y1 = 1.05; + var INK_FONT = 'Chiller, Creepster, "Segoe Script", "Comic Sans MS", cursive'; + var RAY_OFFSETS = [0, 0.5, -0.5, 1, -1, 1.6, -1.6, 2.4, -2.4, Math.PI]; + var WallWriting = class { + enabled = true; + job = { ...IDLE_JOB }; + session = null; + stamps = []; + nextSlot = 0; + lastWrittenStatus = ""; + lastStampX = Number.NaN; + lastStampY = Number.NaN; + lastStampAt = 0; + stampedHistory = /* @__PURE__ */ new Set(); + lastHistoryStampAt = 0; + // Live ghost writing state. The response is written in segments: the + // segment being revealed lives on LIVE_SLOT on the wall the player faces; + // finished segments are committed onto static slots and the text flows on. + live = null; + liveHit = null; + liveText = ""; + liveShown = 0; + liveConsumed = 0; + lastLiveTick = 0; + lastLiveDraw = 0; + lastLiveKey = ""; + lastWallSwitchAt = 0; + atlas; + ctx; + constructor() { + this.atlas = document.createElement("canvas"); + this.atlas.width = this.atlas.height = ATLAS_SIZE; + const ctx = this.atlas.getContext("2d"); + if (!ctx) { + throw new Error("2D context unavailable for wall writing"); + } + this.ctx = ctx; + } + setJob(job) { + this.job = job; + } + setSession(session) { + this.session = session; + } + /** Clears every writing, e.g. on relocate or when the setting turns off. */ + reset(renderer) { + this.job = { ...IDLE_JOB }; + this.session = null; + this.stamps = []; + this.nextSlot = 0; + this.lastWrittenStatus = ""; + this.lastStampX = Number.NaN; + this.lastStampY = Number.NaN; + this.stampedHistory.clear(); + this.live = null; + this.liveHit = null; + this.liveText = ""; + this.liveShown = 0; + this.liveConsumed = 0; + this.lastLiveKey = ""; + renderer.clearDecalMesh(); + } + /** Called once per frame; adds or advances writings as needed. */ + update(now, world, px, py, yaw, renderer) { + if (!this.enabled) { + return; + } + let changed = this.updateJobStamp(now, world, px, py, yaw); + changed = this.updateHistoryStamps(now, world, px, py, yaw) || changed; + changed = this.updateLive(now, world, px, py, yaw) || changed; + if (changed) { + renderer.setDecalTexture(this.atlas); + this.uploadMesh(renderer); + } + } + // --- Job-status stamps (tool / command / status file route) --------------- + updateJobStamp(now, world, px, py, yaw) { + if (!this.job.working || this.job.status.length === 0) { + return false; + } + if (now - this.lastStampAt < STAMP_COOLDOWN_MS) { + return false; + } + const statusChanged = this.job.status !== this.lastWrittenStatus; + const moved = Number.isNaN(this.lastStampX) || Math.hypot(px - this.lastStampX, py - this.lastStampY) >= RESTAMP_DISTANCE; + if (!statusChanged && !moved) { + return false; + } + const hit = findWallAhead(world, px, py, yaw); + if (!hit) { + return false; + } + const edgeKey2 = `${hit.cx},${hit.cy},${hit.direction}`; + const existing = this.stamps.findIndex((s) => s.edgeKey === edgeKey2); + if (existing !== -1 && !statusChanged || this.live?.edgeKey === edgeKey2) { + return false; + } + const slot = existing !== -1 ? this.stamps[existing].slot : this.claimStaticSlot(); + if (existing !== -1) { + this.stamps.splice(existing, 1); + } + if (!this.stampStatic(world, hit, slot, edgeKey2, this.job.status)) { + return false; + } + this.lastWrittenStatus = this.job.status; + this.lastStampX = px; + this.lastStampY = py; + this.lastStampAt = now; + return true; + } + // --- Session history stamps ------------------------------------------------ + updateHistoryStamps(now, world, px, py, yaw) { + if (!this.session || now - this.lastHistoryStampAt < HISTORY_COOLDOWN_MS) { + return false; + } + if (this.stampedHistory.size > 200) { + this.stampedHistory.clear(); + } + for (const exchange of this.session.history) { + const text = exchange.response || exchange.prompt; + if (!text) { + continue; + } + const key = hashText(exchange.prompt + "\0" + exchange.response); + if (this.stampedHistory.has(key)) { + continue; + } + const hit = this.findFreeWall(world, px, py, yaw); + if (!hit) { + return false; + } + const edgeKey2 = `${hit.cx},${hit.cy},${hit.direction}`; + const slot = this.claimStaticSlot(); + if (this.stampStatic(world, hit, slot, edgeKey2, text)) { + this.stampedHistory.add(key); + this.lastHistoryStampAt = now; + return true; + } + } + return false; + } + // --- Live ghost writing ------------------------------------------------------ + updateLive(now, world, px, py, yaw) { + const target = this.session?.current ?? ""; + if (target.length === 0) { + return false; + } + let changed = false; + if (!target.startsWith(this.liveText)) { + changed = this.commitLive(world, Number.MAX_SAFE_INTEGER) || changed; + this.live = null; + this.liveHit = null; + this.liveShown = 0; + this.liveConsumed = 0; + this.lastLiveKey = ""; + } + this.liveText = target; + let segment = target.slice(this.liveConsumed); + if (segment.length === 0 && !this.live) { + return changed; + } + const faced = findWallAhead(world, px, py, yaw); + const facedKey = faced ? `${faced.cx},${faced.cy},${faced.direction}` : null; + if (this.live && faced && facedKey !== this.live.edgeKey && this.isFreeWall(facedKey) && now - this.lastWallSwitchAt > WALL_SWITCH_MS) { + const shownInSegment2 = Math.max(0, Math.floor(this.liveShown) - this.liveConsumed); + if (shownInSegment2 >= MIN_COMMIT_CHARS) { + changed = this.commitLive(world, shownInSegment2) || changed; + } + changed = this.startLive(world, faced) || changed; + this.lastWallSwitchAt = now; + segment = target.slice(this.liveConsumed); + } + if (!this.live) { + const hit = faced && this.isFreeWall(facedKey) ? faced : this.findFreeWall(world, px, py, yaw); + if (!hit || !this.startLive(world, hit)) { + return changed; + } + changed = true; + this.lastWallSwitchAt = now; + } + const dt = Math.min(0.2, (now - this.lastLiveTick) / 1e3 || 0); + this.lastLiveTick = now; + this.liveShown = Math.min(target.length, this.liveShown + dt * REVEAL_CPS); + const shownInSegment = Math.min(segment.length, Math.max(0, Math.floor(this.liveShown) - this.liveConsumed)); + if (shownInSegment >= SEGMENT_CAP) { + changed = this.commitLive(world, shownInSegment) || changed; + return changed; + } + const done = this.session ? !this.session.working : true; + if (done && this.liveConsumed + shownInSegment >= target.length && this.live) { + changed = this.commitLive(world, segment.length) || changed; + return changed; + } + const key = `${shownInSegment}|${segment.length}|${this.live.edgeKey}`; + if (key === this.lastLiveKey || now - this.lastLiveDraw < LIVE_REDRAW_MS) { + return changed; + } + this.lastLiveKey = key; + this.lastLiveDraw = now; + const stillRevealing = this.liveConsumed + shownInSegment < target.length; + this.drawSlot( + LIVE_SLOT, + segment.slice(0, shownInSegment), + stillRevealing ? GHOST_TAIL : 0, + `${this.live.edgeKey}#${this.liveConsumed}` + ); + return true; + } + /** Places (or moves) the live quad onto the given wall, cleared. */ + startLive(world, hit) { + const vertices = buildWallQuad(world, hit, LIVE_SLOT); + if (!vertices) { + return false; + } + this.live = { edgeKey: `${hit.cx},${hit.cy},${hit.direction}`, slot: LIVE_SLOT, vertices }; + this.liveHit = hit; + this.lastLiveKey = ""; + this.drawSlot(LIVE_SLOT, "", 0); + return true; + } + /** + * Freezes up to `maxChars` of the live segment onto a static slot on the + * wall it was written on (cut at a word boundary), advances the consumed + * counter, and frees the live quad. Returns whether anything was drawn. + */ + commitLive(world, maxChars) { + const hit = this.liveHit; + const live = this.live; + this.live = null; + this.liveHit = null; + this.lastLiveKey = ""; + if (!hit || !live) { + return false; + } + const segment = this.liveText.slice(this.liveConsumed); + let cut = Math.min(segment.length, maxChars); + if (cut < segment.length) { + const space = segment.lastIndexOf(" ", cut); + if (space > cut * 0.4) { + cut = space + 1; + } + } + const committed = segment.slice(0, cut).trim(); + this.liveConsumed += cut; + this.liveShown = Math.max(this.liveShown, this.liveConsumed); + if (committed.length === 0) { + return false; + } + const slot = this.claimStaticSlot(); + const vertices = buildWallQuad(world, hit, slot); + if (!vertices) { + return false; + } + this.drawSlot(slot, committed, 0); + this.stamps.push({ edgeKey: live.edgeKey, slot, vertices }); + return true; + } + isFreeWall(edgeKey2) { + return this.live?.edgeKey !== edgeKey2 && !this.stamps.some((s) => s.edgeKey === edgeKey2); + } + // --- Shared helpers ---------------------------------------------------------- + /** Next rotating static slot, evicting whatever writing used it before. */ + claimStaticSlot() { + const slot = this.nextSlot % STATIC_SLOTS; + this.nextSlot++; + this.stamps = this.stamps.filter((s) => s.slot !== slot); + return slot; + } + stampStatic(world, hit, slot, edgeKey2, text) { + const vertices = buildWallQuad(world, hit, slot); + if (!vertices) { + return false; + } + this.drawSlot(slot, text, 0); + this.stamps.push({ edgeKey: edgeKey2, slot, vertices }); + return true; + } + /** First unclaimed wall found by fanning rays around the player's gaze. */ + findFreeWall(world, px, py, yaw) { + for (const offset of RAY_OFFSETS) { + const hit = findWallAhead(world, px, py, yaw + offset); + if (hit && this.isFreeWall(`${hit.cx},${hit.cy},${hit.direction}`)) { + return hit; + } + } + return null; + } + uploadMesh(renderer) { + const vertices = []; + const indices = []; + const all = this.live ? [...this.stamps, this.live] : this.stamps; + for (const stamp of all) { + const base = vertices.length / 6; + vertices.push(...stamp.vertices); + indices.push(base, base + 1, base + 2, base, base + 2, base + 3); + } + renderer.setDecalMesh(new Float32Array(vertices), new Uint16Array(indices)); + } + /** + * Draws one writing into an atlas slot with the marker treatment: glyphs + * jittered like handwriting, outline copies extruded ~3px toward the wall, + * black fill under a very subtle drop shadow, splatter, and finally a + * low-opacity gradient overlay (angle and stops seeded per text) that + * shifts across the strokes like uneven marker ink. + * + * `ghostTail` renders the last N characters progressively fainter, for the + * live response materializing onto the wall. + */ + drawSlot(slot, text, ghostTail, seedText) { + const ctx = this.ctx; + const ox = slot % SLOT_COLS * SLOT_W; + const oy = Math.floor(slot / SLOT_COLS) * SLOT_H; + ctx.save(); + ctx.clearRect(ox, oy, SLOT_W, SLOT_H); + if (text.trim().length === 0) { + ctx.restore(); + return; + } + ctx.beginPath(); + ctx.rect(ox, oy, SLOT_W, SLOT_H); + ctx.clip(); + const rng = mulberry(hashCode(seedText ?? text) || 1); + let size = 80; + let lines = [text.trim()]; + for (; size > 26; size -= 4) { + ctx.font = `bold ${size}px ${INK_FONT}`; + lines = wrapToLines(ctx, text, SLOT_W - 70); + if (lines.length * size * 1.12 <= SLOT_H - 36) { + break; + } + } + const maxLines = Math.max(1, Math.floor((SLOT_H - 36) / (size * 1.12))); + if (lines.length > maxLines) { + lines = lines.slice(0, maxLines); + lines[maxLines - 1] += "\u2026"; + } + const ink = (alpha) => `rgba(16, 14, 13, ${alpha.toFixed(3)})`; + const extrude = (alpha) => `rgba(34, 30, 27, ${alpha.toFixed(3)})`; + ctx.textBaseline = "alphabetic"; + const lineGap = size * 1.12; + const blockH = lineGap * (lines.length - 1); + const baseY = oy + SLOT_H / 2 - blockH / 2 + size * 0.34; + const totalChars = lines.reduce((n, line) => n + line.length, 0); + let drawnChars = 0; + for (let li = 0; li < lines.length; li++) { + const line = lines[li]; + ctx.font = `bold ${size}px ${INK_FONT}`; + const lineW = ctx.measureText(line).width; + let x = ox + (SLOT_W - lineW) / 2 + (rng() - 0.5) * 16; + const y = baseY + li * lineGap + (rng() - 0.5) * 8; + for (const char of line) { + const w = ctx.measureText(char).width; + const fromEnd = totalChars - drawnChars; + const ghost = ghostTail > 0 && fromEnd <= ghostTail ? fromEnd / (ghostTail + 1) : 0; + const alpha = (0.82 + rng() * 0.14) * (1 - ghost * 0.75); + ctx.save(); + ctx.translate(x + w / 2, y + (rng() - 0.5) * size * 0.09); + ctx.rotate((rng() - 0.5) * 0.14); + ctx.shadowColor = "transparent"; + for (const depth of [3, 2, 1]) { + ctx.fillStyle = extrude(0.2 * (1 - ghost)); + ctx.fillText(char, -w / 2 + depth, depth * 0.8); + } + ctx.shadowColor = "rgba(0, 0, 0, 0.3)"; + ctx.shadowBlur = 2; + ctx.shadowOffsetX = 1; + ctx.shadowOffsetY = 1.5; + ctx.fillStyle = ink(alpha); + ctx.fillText(char, -w / 2, 0); + ctx.restore(); + x += w * (0.94 + rng() * 0.06); + drawnChars++; + if (rng() < 0.07 && ghost === 0) { + const dripLen = 16 + rng() * 56; + const dripX = x - w / 2 + (rng() - 0.5) * 6; + const grad2 = ctx.createLinearGradient(0, y, 0, y + dripLen); + grad2.addColorStop(0, ink(0.55)); + grad2.addColorStop(1, ink(0)); + ctx.fillStyle = grad2; + ctx.fillRect(dripX, y - 2, 1.4 + rng() * 1.6, dripLen); + } + } + } + ctx.shadowColor = "transparent"; + ctx.shadowBlur = 0; + const splats = 5 + Math.floor(rng() * 6); + for (let i = 0; i < splats; i++) { + const sx = ox + 30 + rng() * (SLOT_W - 60); + const sy = oy + 30 + rng() * (SLOT_H - 60); + ctx.fillStyle = ink(0.2 + rng() * 0.4); + ctx.beginPath(); + ctx.arc(sx, sy, 0.8 + rng() * rng() * 5, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalCompositeOperation = "source-atop"; + const theta = rng() * Math.PI * 2; + const r = Math.max(SLOT_W, SLOT_H) / 2; + const cxm = ox + SLOT_W / 2; + const cym = oy + SLOT_H / 2; + const grad = ctx.createLinearGradient( + cxm - Math.cos(theta) * r, + cym - Math.sin(theta) * r, + cxm + Math.cos(theta) * r, + cym + Math.sin(theta) * r + ); + let pos = 0; + let bright = rng() < 0.5; + while (pos < 1) { + const alpha = 0.04 + rng() * 0.08; + grad.addColorStop(pos, bright ? `rgba(255, 255, 255, ${alpha.toFixed(3)})` : `rgba(30, 30, 30, ${alpha.toFixed(3)})`); + pos += 0.12 + rng() * 0.2; + bright = !bright; + } + ctx.fillStyle = grad; + ctx.fillRect(ox, oy, SLOT_W, SLOT_H); + ctx.globalCompositeOperation = "source-over"; + ctx.restore(); + } + }; + function wrapToLines(ctx, text, maxWidth) { + const words = text.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) { + return [""]; + } + const lines = []; + let line = ""; + for (const word of words) { + const candidate = line ? `${line} ${word}` : word; + if (line && ctx.measureText(candidate).width > maxWidth) { + lines.push(line); + line = word; + } else { + line = candidate; + } + } + lines.push(line); + return lines; + } + function hashCode(text) { + let hash = 2166136261; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; + } + function hashText(text) { + return hashCode(text).toString(36); + } + function mulberry(seed) { + let state = seed >>> 0; + return () => { + state = state + 1831565813 >>> 0; + let t = state; + t = Math.imul(t ^ t >>> 15, t | 1); + t ^= t + Math.imul(t ^ t >>> 7, t | 61); + return ((t ^ t >>> 14) >>> 0) / 4294967296; + }; + } + function findWallAhead(world, px, py, yaw) { + const fx = -Math.sin(yaw); + const fy = -Math.cos(yaw); + let cx = Math.floor(px); + let cy = Math.floor(py); + const stepX = fx > 0 ? 1 : -1; + const stepY = fy > 0 ? 1 : -1; + const tDeltaX = fx !== 0 ? 1 / Math.abs(fx) : Infinity; + const tDeltaY = fy !== 0 ? 1 / Math.abs(fy) : Infinity; + let tMaxX = fx !== 0 ? (fx > 0 ? cx + 1 - px : px - cx) / Math.abs(fx) : Infinity; + let tMaxY = fy !== 0 ? (fy > 0 ? cy + 1 - py : py - cy) / Math.abs(fy) : Infinity; + for (let i = 0; i < MAX_WRITE_DISTANCE * 2; i++) { + let direction; + let t; + if (tMaxX < tMaxY) { + direction = stepX > 0 ? "east" : "west"; + t = tMaxX; + tMaxX += tDeltaX; + } else { + direction = stepY > 0 ? "south" : "north"; + t = tMaxY; + tMaxY += tDeltaY; + } + if (t > MAX_WRITE_DISTANCE) { + return null; + } + if (!world.generator.isPassable(cx, cy, direction)) { + return { cx, cy, direction, hitX: px + fx * t, hitY: py + fy * t }; + } + const d = step(direction); + cx += d.dx; + cy += d.dy; + } + return null; + } + function buildWallQuad(world, hit, slot) { + const cell = world.generator.getCell(hit.cx, hit.cy); + const edge = cell.edges[hit.direction]; + const inward = step(opposite(hit.direction)); + const off = WALL_HALF_DEPTH + 8e-3; + let ax = edge.start.x + inward.dx * off; + let az = edge.start.y + inward.dy * off; + let bx = edge.end.x + inward.dx * off; + let bz = edge.end.y + inward.dy * off; + if (-(bz - az) * inward.dx + (bx - ax) * inward.dy < 0) { + [ax, bx] = [bx, ax]; + [az, bz] = [bz, az]; + } + const len = Math.hypot(bx - ax, bz - az); + const width = Math.min(1.5, len - 0.24); + if (width < 0.6) { + return null; + } + const ux = (bx - ax) / len; + const uz = (bz - az) / len; + const hitU = (hit.hitX - ax) * ux + (hit.hitY - az) * uz; + const center2 = Math.min(len - 0.12 - width / 2, Math.max(0.12 + width / 2, hitU)); + const x0 = ax + ux * (center2 - width / 2); + const z0 = az + uz * (center2 - width / 2); + const x1 = ax + ux * (center2 + width / 2); + const z1 = az + uz * (center2 + width / 2); + const shade = 0.42 + 0.5 * world.lightAt((x0 + x1) / 2 + inward.dx * 0.2, (z0 + z1) / 2 + inward.dy * 0.2); + const u0 = slot % SLOT_COLS * SLOT_W / ATLAS_SIZE; + const v0 = Math.floor(slot / SLOT_COLS) * SLOT_H / ATLAS_SIZE; + const u1 = u0 + SLOT_W / ATLAS_SIZE; + const v1 = v0 + SLOT_H / ATLAS_SIZE; + return [ + x0, + WRITE_Y0, + z0, + u0, + v1, + shade, + x1, + WRITE_Y0, + z1, + u1, + v1, + shade, + x1, + WRITE_Y1, + z1, + u1, + v0, + shade, + x0, + WRITE_Y1, + z0, + u0, + v0, + shade + ]; + } + + // src/webview/input.ts + var Input = class { + constructor(surface) { + this.surface = surface; + window.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + this.onMenuToggle?.(); + return; + } + const target = e.target; + if (target instanceof HTMLElement && (target.matches("input, select, textarea, button") || target.isContentEditable)) { + return; + } + if (e.key.toLowerCase() === "m") { + this.onMenuToggle?.(); + return; + } + this.held.add(normalize(e.key)); + if (isGameKey(e.key)) { + e.preventDefault(); + } + }); + window.addEventListener("keyup", (e) => this.held.delete(normalize(e.key))); + window.addEventListener("blur", () => this.held.clear()); + surface.addEventListener("click", () => { + if (this.mouseLookEnabled && document.pointerLockElement !== surface) { + surface.requestPointerLock(); + } + }); + document.addEventListener("mousemove", (e) => { + if (document.pointerLockElement === this.surface) { + this.lookDx += e.movementX; + this.lookDy += e.movementY; + } + }); + } + held = /* @__PURE__ */ new Set(); + lookDx = 0; + mouseLookEnabled = true; + onMenuToggle = null; + lookDy = 0; + releasePointer() { + if (document.pointerLockElement === this.surface) { + document.exitPointerLock(); + } + } + /** Accumulated mouse-look delta since the last call, in pixels. */ + consumeLook() { + const out = { dx: this.lookDx, dy: this.lookDy }; + this.lookDx = 0; + this.lookDy = 0; + return out; + } + state() { + const has = (...keys) => keys.some((k) => this.held.has(k)); + return { + forward: (has("w", "arrowup") ? 1 : 0) - (has("s", "arrowdown") ? 1 : 0), + strafe: (has("d") ? 1 : 0) - (has("a") ? 1 : 0), + turn: (has("arrowright", "e") ? 1 : 0) - (has("arrowleft", "q") ? 1 : 0), + running: has("shift") + }; + } + }; + function normalize(key) { + return key.toLowerCase(); + } + function isGameKey(key) { + return ["w", "a", "s", "d", "q", "e", "shift"].includes(key.toLowerCase()) || key.startsWith("Arrow"); + } + + // src/webview/monster.ts + var BODY_FORMS = ["spider", "humanoid", "cloud"]; + var CATCH_DISTANCE = 0.45; + var REPATH_MS = 600; + var PATH_NODE_CAP = 900; + var DARK = [1, 1, 1]; + var BODY_SHADE = 0.16; + var EYE_TINT = [1, 0.16, 0.1]; + var Monster = class { + constructor(world, config, now) { + this.world = world; + this.config = config; + this.arm(now); + } + form = "spider"; + x = 0; + y = 0; + config; + stalking = false; + spawnAt = 0; + path = []; + lastPathAt = 0; + phase = 0; + heading = 0; + get isStalking() { + return this.stalking; + } + /** Applies new tuning live; the spawn window only affects future arms. */ + configure(config) { + const formChanged = config.form !== this.config.form; + this.config = config; + if (formChanged && config.form !== "random") { + this.form = config.form; + } + } + /** Returns to dormant and schedules the next appearance. */ + arm(now) { + this.stalking = false; + this.path = []; + const min = Math.min(this.config.spawnMinMs, this.config.spawnMaxMs); + const max = Math.max(this.config.spawnMinMs, this.config.spawnMaxMs); + this.spawnAt = now + min + Math.random() * (max - min); + } + update(now, dt, px, py) { + if (!this.stalking) { + if (now >= this.spawnAt) { + this.spawn(px, py); + return "spawned"; + } + return null; + } + const distToPlayer = Math.hypot(px - this.x, py - this.y); + if (distToPlayer < CATCH_DISTANCE) { + this.arm(now); + return "caught"; + } + if (now - this.lastPathAt > REPATH_MS) { + this.lastPathAt = now; + this.path = this.findPath(Math.floor(this.x), Math.floor(this.y), Math.floor(px), Math.floor(py)); + } + let target = this.path[0] ? center(this.path[0]) : this.greedyStep(px, py); + if (this.path.length <= 1 && distToPlayer < 1.4) { + target = { x: px, y: py }; + } + const dx = target.x - this.x; + const dy = target.y - this.y; + const dist = Math.hypot(dx, dy); + const travel = this.config.speed * dt; + if (dist > 1e-4) { + const t = Math.min(1, travel / dist); + this.x += dx * t; + this.y += dy * t; + this.heading = Math.atan2(dy, dx); + this.phase += travel * 4.4; + } + if (this.path[0] && Math.hypot(center(this.path[0]).x - this.x, center(this.path[0]).y - this.y) < 0.08) { + this.path.shift(); + } + return null; + } + spawn(px, py) { + if (this.config.form === "random") { + this.form = BODY_FORMS[Math.floor(Math.random() * BODY_FORMS.length)]; + } else { + this.form = this.config.form; + } + const angle = Math.random() * Math.PI * 2; + const dist = 9 + Math.random() * 5; + this.x = Math.floor(px + Math.cos(angle) * dist) + 0.5; + this.y = Math.floor(py + Math.sin(angle) * dist) + 0.5; + this.path = []; + this.lastPathAt = 0; + this.stalking = true; + } + /** Breadth-first search through open edges, capped for the infinite grid. */ + findPath(fromCx, fromCy, toCx, toCy) { + if (fromCx === toCx && fromCy === toCy) { + return []; + } + const key = (cx, cy) => `${cx},${cy}`; + const parents = /* @__PURE__ */ new Map(); + parents.set(key(fromCx, fromCy), null); + const queue = [{ cx: fromCx, cy: fromCy }]; + let found = false; + while (queue.length > 0 && parents.size < PATH_NODE_CAP) { + const node = queue.shift(); + if (node.cx === toCx && node.cy === toCy) { + found = true; + break; + } + for (const direction of DIRECTIONS) { + if (!this.world.generator.isPassable(node.cx, node.cy, direction)) { + continue; + } + const { dx, dy } = step(direction); + const next = { cx: node.cx + dx, cy: node.cy + dy }; + const nextKey = key(next.cx, next.cy); + if (!parents.has(nextKey)) { + parents.set(nextKey, key(node.cx, node.cy)); + queue.push(next); + } + } + } + if (!found) { + return []; + } + const path = []; + let cursor = key(toCx, toCy); + while (cursor && cursor !== key(fromCx, fromCy)) { + const [cx, cy] = cursor.split(",").map(Number); + path.unshift({ cx, cy }); + cursor = parents.get(cursor) ?? null; + } + return path; + } + /** No path known: shuffle toward the player through any open edge. */ + greedyStep(px, py) { + const cx = Math.floor(this.x); + const cy = Math.floor(this.y); + let best = { x: this.x, y: this.y }; + let bestDist = Number.POSITIVE_INFINITY; + for (const direction of DIRECTIONS) { + if (!this.world.generator.isPassable(cx, cy, direction)) { + continue; + } + const { dx, dy } = step(direction); + const candidate = center({ cx: cx + dx, cy: cy + dy }); + const dist = Math.hypot(px - candidate.x, py - candidate.y); + if (dist < bestDist) { + bestDist = dist; + best = candidate; + } + } + return best; + } + /** Emits this frame's world-space geometry. */ + buildMesh(now, px, py) { + const b = new MeshBuilder(); + switch (this.form) { + case "spider": + this.buildSpider(b); + break; + case "humanoid": + this.buildHumanoid(b); + break; + case "cloud": + this.buildCloud(b, now); + break; + } + this.buildEyes(b, px, py); + return { vertices: b.vertices(), indices: b.indices() }; + } + buildSpider(b) { + const { x, y } = this; + emitBox(b, x - 0.19, x + 0.19, 0.2, 0.42, y - 0.23, y + 0.23, TILE.fabric, DARK, BODY_SHADE); + emitBox(b, x - 0.09, x + 0.09, 0.26, 0.4, y - 0.34, y - 0.2, TILE.fabric, DARK, BODY_SHADE * 1.2); + for (let i = 0; i < 8; i++) { + const side = i < 4 ? -1 : 1; + const spread = (i % 4 - 1.5) * 0.5 + this.heading; + const lift = Math.max(0, Math.sin(this.phase + i * (Math.PI / 2))) * 0.1; + const hip = [x + side * 0.17, 0.34, y + Math.sin(spread) * 0.15]; + const foot = [ + x + side * (0.5 + 0.1 * Math.sin(i * 2.1)), + lift, + y + Math.sin(spread) * 0.42 + Math.cos(spread) * side * 0.12 + ]; + this.limb(b, hip, foot, 0.045); + } + } + buildHumanoid(b) { + const { x, y } = this; + const sway = Math.sin(this.phase * 0.5) * 0.03; + emitBox(b, x - 0.11 + sway, x + 0.11 + sway, 0.52, 1.06, y - 0.07, y + 0.07, TILE.fabric, DARK, BODY_SHADE); + emitBox(b, x - 0.06 + sway, x + 0.06 + sway, 1.06, 1.2, y - 0.06, y + 0.06, TILE.fabric, DARK, BODY_SHADE * 1.15); + const strideX = Math.cos(this.heading) * 0.16; + const strideY = Math.sin(this.heading) * 0.16; + const gait = Math.sin(this.phase); + this.limb(b, [x - 0.06, 0.55, y], [x - 0.06 + strideX * gait, 0, y + strideY * gait], 0.05); + this.limb(b, [x + 0.06, 0.55, y], [x + 0.06 - strideX * gait, 0, y - strideY * gait], 0.05); + this.limb(b, [x - 0.13 + sway, 1, y], [x - 0.13 + sway - strideX * gait * 0.5, 0.5, y - strideY * gait * 0.5], 0.04); + this.limb(b, [x + 0.13 + sway, 1, y], [x + 0.13 + sway + strideX * gait * 0.5, 0.5, y + strideY * gait * 0.5], 0.04); + } + buildCloud(b, now) { + const { x, y } = this; + const t = now / 1e3; + for (let i = 0; i < 10; i++) { + const jx = Math.sin(t * 1.3 + i * 2.4) * 0.14; + const jy = Math.sin(t * 1.7 + i * 1.9) * 0.1; + const jz = Math.cos(t * 1.1 + i * 3.2) * 0.14; + const size = 0.12 + i * 37 % 10 * 0.02; + const cx = x + Math.sin(i * 2.4) * 0.2 + jx; + const cy = 0.45 + Math.sin(i * 1.6) * 0.3 + jy; + const cz = y + Math.cos(i * 2.9) * 0.2 + jz; + emitBox(b, cx - size, cx + size, cy - size, cy + size, cz - size, cz + size, TILE.concrete, DARK, BODY_SHADE * (0.8 + i % 3 * 0.2)); + } + } + /** Two small emissive eyes billboarded toward the player. */ + buildEyes(b, px, py) { + const eyeHeight = this.form === "spider" ? 0.36 : this.form === "humanoid" ? 1.13 : 0.7; + const toPlayerX = px - this.x; + const toPlayerY = py - this.y; + const len = Math.hypot(toPlayerX, toPlayerY) || 1; + const fx = toPlayerX / len; + const fy = toPlayerY / len; + const rx = -fy; + const ry = fx; + const ex = this.x + fx * 0.2; + const ey = this.y + fy * 0.2; + const r = 0.03; + for (const side of [-1, 1]) { + const cx = ex + rx * side * 0.06; + const cy = ey + ry * side * 0.06; + b.quad( + [cx - rx * r, eyeHeight - r, cy - ry * r], + [cx + rx * r, eyeHeight - r, cy + ry * r], + [cx + rx * r, eyeHeight + r, cy + ry * r], + [cx - rx * r, eyeHeight + r, cy - ry * r], + [0, 0], + [1, 0], + [1, 1], + [0, 1], + TILE.lightPanel, + EYE_TINT, + [EMISSIVE_SHADE + 0.8, EMISSIVE_SHADE + 0.8, EMISSIVE_SHADE + 0.8, EMISSIVE_SHADE + 0.8] + ); + } + } + /** A thin double-sided crossed-quad limb between two points. */ + limb(b, from, to, width) { + const shades = [BODY_SHADE, BODY_SHADE, BODY_SHADE, BODY_SHADE]; + const uv = [ + [0, 0], + [1, 0], + [1, 1], + [0, 1] + ]; + const planes = [ + [width, 0, 0], + [0, 0, width] + ]; + for (const offset of planes) { + const a0 = [from[0] - offset[0], from[1] - offset[1], from[2] - offset[2]]; + const a1 = [from[0] + offset[0], from[1] + offset[1], from[2] + offset[2]]; + const b1 = [to[0] + offset[0], to[1] + offset[1], to[2] + offset[2]]; + const b0 = [to[0] - offset[0], to[1] - offset[1], to[2] - offset[2]]; + b.quad(a0, a1, b1, b0, uv[0], uv[1], uv[2], uv[3], TILE.fabric, DARK, shades); + b.quad(b0, b1, a1, a0, uv[0], uv[1], uv[2], uv[3], TILE.fabric, DARK, shades); + } + } + }; + function center(cell) { + return { x: cell.cx + 0.5, y: cell.cy + 0.5 }; + } + + // src/webview/menu.ts + var STYLE = ` +.bv-menu { + position: absolute; inset: 0; display: none; z-index: 30; + align-items: center; justify-content: center; + background: rgba(16, 17, 19, 0.72); + font-family: "Segoe UI", system-ui, sans-serif; + color: #f0f2f3; +} +.bv-menu.open { display: flex; } +.bv-card { + background: linear-gradient(#22252a, #1c1f23); + border: 1px solid #3a3f46; border-radius: 10px; + min-width: 300px; max-width: 380px; max-height: 82%; overflow-y: auto; + padding: 22px 26px; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55); +} +.bv-card h1 { margin: 0 0 2px; font-size: 20px; letter-spacing: 3px; font-weight: 600; } +.bv-card .bv-sub { margin: 0 0 18px; font-size: 12px; color: #9aa3ad; } +.bv-menu button { + display: block; width: 100%; margin: 8px 0; padding: 10px 14px; + background: #2b3036; color: #f0f2f3; border: 1px solid #454c55; + border-radius: 6px; font-size: 14px; cursor: pointer; text-align: left; +} +.bv-menu button:hover { background: #343a42; } +.bv-menu button.bv-accent { background: #6b4b12; border-color: #93691c; } +.bv-menu button.bv-accent:hover { background: #7d5915; } +.bv-row { display: flex; align-items: center; justify-content: space-between; margin: 10px 0; font-size: 13px; gap: 12px; } +.bv-row label { flex: 1; color: #cfd6dc; } +.bv-row input[type="checkbox"] { width: 16px; height: 16px; accent-color: #93691c; } +.bv-row input[type="range"] { width: 130px; accent-color: #93691c; } +.bv-row input[type="number"] { + width: 110px; background: #15171a; color: #f0f2f3; + border: 1px solid #454c55; border-radius: 4px; padding: 5px 7px; font-size: 13px; +} +.bv-row .bv-val { width: 34px; text-align: right; color: #9aa3ad; font-variant-numeric: tabular-nums; } +.bv-row select { + background: #15171a; color: #f0f2f3; border: 1px solid #454c55; + border-radius: 4px; padding: 5px 7px; font-size: 13px; min-width: 150px; +} +.bv-h { + margin: 16px 0 4px; font-size: 11px; letter-spacing: 2px; + color: #8a93a0; text-transform: uppercase; +} +.bv-h:first-of-type { margin-top: 8px; } +.bv-stats { margin-top: 14px; padding-top: 12px; border-top: 1px solid #33383f; font-size: 12px; color: #9aa3ad; line-height: 1.7; } +.bv-help { font-size: 13px; color: #cfd6dc; line-height: 1.9; } +.bv-help kbd { + background: #15171a; border: 1px solid #454c55; border-radius: 4px; + padding: 1px 6px; font-family: inherit; font-size: 12px; +} +.bv-back { margin-top: 16px !important; } +`; + var Menu = class { + constructor(parent, callbacks) { + this.callbacks = callbacks; + const style = document.createElement("style"); + style.textContent = STYLE; + document.head.appendChild(style); + this.root = document.createElement("div"); + this.root.className = "bv-menu"; + parent.appendChild(this.root); + this.views = { + main: this.buildMain(), + settings: this.buildSettings(), + help: this.buildHelp() + }; + for (const view of Object.values(this.views)) { + this.root.appendChild(view); + } + this.show("main"); + } + root; + views; + statsEl = null; + settings = null; + openFlag = false; + lastFocused = null; + get isOpen() { + return this.openFlag; + } + open() { + this.openFlag = true; + this.lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + this.root.classList.add("open"); + this.show("main"); + } + close() { + this.openFlag = false; + this.root.classList.remove("open"); + if (this.lastFocused?.isConnected) { + this.lastFocused.focus(); + } + this.lastFocused = null; + } + syncSettings(settings) { + this.settings = settings; + for (const input of this.root.querySelectorAll("[data-key]")) { + const key = input.dataset.key; + const value = settings[key]; + if (input.type === "checkbox") { + input.checked = Boolean(value); + } else { + input.value = String(value); + const label = input.parentElement?.querySelector(".bv-val"); + if (label) { + label.textContent = String(value); + } + } + } + } + syncStats(stats) { + if (this.statsEl) { + this.statsEl.innerHTML = `seed ${stats.seed}
cells visited ${stats.cellsVisited}
cells cached ${stats.cacheSize}`; + } + } + show(name) { + for (const [key, view] of Object.entries(this.views)) { + view.style.display = key === name ? "block" : "none"; + } + // Only the visible card is a live dialog; hand it focus so assistive + // tech announces the context change instead of staying on the game. + if (this.openFlag) { + const view = this.views[name]; + const target = view.querySelector("button, input, select") ?? view; + target.focus(); + } + } + card() { + const card = document.createElement("div"); + card.className = "bv-card"; + card.setAttribute("role", "dialog"); + card.setAttribute("aria-modal", "true"); + card.setAttribute("aria-label", "BackRooms pause menu"); + card.tabIndex = -1; + card.innerHTML = '

BACKROOMS

noclipped into a Copilot canvas

'; + return card; + } + button(label, onClick, accent = false) { + const button = document.createElement("button"); + button.textContent = label; + if (accent) { + button.className = "bv-accent"; + } + button.addEventListener("click", onClick); + return button; + } + buildMain() { + const card = this.card(); + card.appendChild(this.button("Resume", () => this.callbacks.onResume())); + card.appendChild(this.button("Relocate (new seed)", () => this.callbacks.onRelocate(), true)); + card.appendChild(this.button("Settings", () => this.show("settings"))); + card.appendChild(this.button("Help", () => this.show("help"))); + this.statsEl = document.createElement("div"); + this.statsEl.className = "bv-stats"; + card.appendChild(this.statsEl); + return card; + } + toggleRow(label, key) { + const row = document.createElement("div"); + row.className = "bv-row"; +const input = document.createElement("input"); + input.type = "checkbox"; + input.dataset.key = key; + input.setAttribute("aria-label", label); + input.addEventListener("change", () => this.callbacks.onSettingChange(key, input.checked)); + const text = document.createElement("label"); + text.textContent = label; + row.append(text, input); + return row; + } + sliderRow(label, key, min, max, stepSize) { + const row = document.createElement("div"); + row.className = "bv-row"; + const text = document.createElement("label"); + text.textContent = label; + const value = document.createElement("span"); + value.className = "bv-val"; +const input = document.createElement("input"); + input.type = "range"; + input.min = String(min); + input.max = String(max); + input.step = String(stepSize); + input.dataset.key = key; + input.setAttribute("aria-label", label); + input.addEventListener("input", () => { + value.textContent = input.value; + this.callbacks.onSettingChange(key, Number(input.value)); + }); + row.append(text, input, value); + return row; + } + heading(text) { + const h = document.createElement("div"); + h.className = "bv-h"; + h.textContent = text; + return h; + } + selectRow(label, key, options) { + const row = document.createElement("div"); + row.className = "bv-row"; + const text = document.createElement("label"); + text.textContent = label; +const select = document.createElement("select"); + select.dataset.key = key; + select.setAttribute("aria-label", label); + for (const option of options) { + const el = document.createElement("option"); + el.value = option.value; + el.textContent = option.label; + select.appendChild(el); + } + select.addEventListener("change", () => this.callbacks.onSettingChange(key, select.value)); + row.append(text, select); + return row; + } + buildSettings() { + const card = this.card(); + card.appendChild(this.heading("Camera")); + card.appendChild(this.toggleRow("Camera shake", "cameraShake")); + card.appendChild(this.toggleRow("Film grain", "filmGrain")); + card.appendChild(this.toggleRow("Camcorder HUD", "vhsHud")); + card.appendChild(this.heading("Controls")); + card.appendChild(this.toggleRow("Mouse look", "mouseLook")); + card.appendChild(this.toggleRow("Invert turn", "invertTurn")); + card.appendChild(this.toggleRow("Invert strafe", "invertStrafe")); + card.appendChild(this.toggleRow("Invert forward/back", "invertForward")); + card.appendChild(this.sliderRow("Walk speed", "moveSpeed", 0.5, 6, 0.1)); + card.appendChild(this.heading("Materials")); + card.appendChild( + this.selectRow("Wall material", "materialPreset", [ + { value: "classic", label: "Classic wallpaper mix" }, + { value: "office", label: "Plain drywall" }, + { value: "pool", label: "Ceramic tile" }, + { value: "concrete", label: "Bare concrete" }, + { value: "panel", label: "Wood paneling" } + ]) + ); + card.appendChild(this.sliderRow("Hue shift", "materialHueShift", -180, 180, 5)); + card.appendChild(this.sliderRow("Brightness", "materialBrightness", 0.6, 1.4, 0.05)); + card.appendChild(this.heading("Monster")); + card.appendChild(this.toggleRow("Monster", "monsterEnabled")); + card.appendChild( + this.selectRow("Form", "monsterForm", [ + { value: "spider", label: "Spider-like" }, + { value: "humanoid", label: "Human-like" }, + { value: "cloud", label: "Cloud-like" }, + { value: "random", label: "Random each spawn" } + ]) + ); + card.appendChild(this.sliderRow("Speed", "monsterSpeed", 0.5, 5, 0.1)); + card.appendChild(this.sliderRow("Spawn after (min)", "monsterSpawnMin", 0.1, 10, 0.1)); + card.appendChild(this.sliderRow("Spawn before (min)", "monsterSpawnMax", 0.5, 15, 0.5)); + card.appendChild(this.heading("World")); + card.appendChild(this.toggleRow("Furniture", "furniture")); + card.appendChild(this.toggleRow("Wallpaper shifts", "wallpaperShifts")); + card.appendChild(this.sliderRow("Render distance", "renderDistance", 6, 28, 1)); + card.appendChild(this.heading("Copilot")); + card.appendChild(this.toggleRow("Ghost-writer on the walls", "copilotGhostWriter")); + const seedRow = document.createElement("div"); + seedRow.className = "bv-row"; + const seedLabel = document.createElement("label"); + seedLabel.textContent = "Seed (0 = random)"; +const seedInput = document.createElement("input"); + seedInput.type = "number"; + seedInput.dataset.key = "seed"; + seedInput.setAttribute("aria-label", "Seed (0 = random)"); + seedInput.addEventListener("change", () => { + const seed = Math.trunc(Number(seedInput.value)) || 0; + this.callbacks.onSettingChange("seed", seed); + this.callbacks.onReseed(seed); + }); + seedRow.append(seedLabel, seedInput); + card.appendChild(seedRow); + const back = this.button("Back", () => this.show("main")); + back.classList.add("bv-back"); + card.appendChild(back); + return card; + } + buildHelp() { + const card = this.card(); + const help = document.createElement("div"); + help.className = "bv-help"; + help.innerHTML = "W/S or / walk
A/D strafe
/ or Q/E turn
Shift hurry
click the view for mouse look
M or Esc open this menu

The maze is infinite and deterministic: the same seed always rebuilds the same rooms."; + card.appendChild(help); + const back = this.button("Back", () => this.show("main")); + back.classList.add("bv-back"); + card.appendChild(back); + return card; + } + }; + + // src/webview/main.ts + var EYE_HEIGHT = 0.78; + var vscode = acquireVsCodeApi(); + var Game = class { + settings = { ...DEFAULT_SETTINGS }; + renderer; + film; + graffiti = new WallWriting(); + input; + menu; + toast; + world; + monster = null; + uniformWallpaper = false; + // Player, in plane coordinates. + px = 0.5; + py = 0.5; + yaw = 0; + pitch = 0; + bobPhase = 0; + lastFrame = 0; + lastPersist = 0; + flickerDipUntil = 0; + toastTimer; + constructor(root) { + const glCanvas = document.createElement("canvas"); + const filmCanvas = document.createElement("canvas"); + for (const [canvas, z] of [[glCanvas, "1"], [filmCanvas, "2"]]) { + canvas.style.cssText = `position:absolute;inset:0;width:100%;height:100%;z-index:${z};`; + } + filmCanvas.style.pointerEvents = "none"; + root.append(glCanvas, filmCanvas); + this.renderer = new Renderer(glCanvas); + this.film = new FilmOverlay(filmCanvas); + this.input = new Input(glCanvas); + this.input.onMenuToggle = () => this.toggleMenu(); + this.menu = new Menu(root, { + onResume: () => this.toggleMenu(), + onRelocate: () => this.relocate(), + onSettingChange: (key, value) => this.changeSetting(key, value), + // A nonzero seed already rebuilds through applySettings; 0 means "roll one now". + onReseed: (seed2) => { + if (seed2 === 0) { + this.rebuildWorld(randomSeed()); + } + } + }); + this.toast = document.createElement("div"); + this.toast.setAttribute("role", "status"); + this.toast.style.cssText = 'position:absolute;left:50%;bottom:9%;transform:translateX(-50%);z-index:20;background:rgba(22,24,27,0.85);color:#f0f2f3;border:1px solid #454c55;border-radius:6px;padding:8px 16px;font:13px "Segoe UI",system-ui,sans-serif;opacity:0;transition:opacity .4s;pointer-events:none;'; + root.appendChild(this.toast); + const restored = vscode.getState(); + const seed = restored?.seed ?? (this.settings.seed !== 0 ? this.settings.seed : randomSeed()); + this.world = new World(seed); + if (restored) { + this.px = restored.px; + this.py = restored.py; + this.yaw = restored.yaw; + this.world.session.warpTo(Math.floor(this.px), Math.floor(this.py)); + } + window.addEventListener("message", (event) => { + if (event.source !== window || !event.data || typeof event.data !== "object") { + return; + } + const message = event.data; + if (message.type === "config") { + this.applySettings(message.settings); + } else if (message.type === "relocate") { + this.relocate(message.seed); + } else if (message.type === "jobStatus") { + this.film.setJob(message.job); + this.graffiti.setJob(message.job); + } else if (message.type === "chatSession") { + this.film.setJob({ + working: message.session.working, + status: "", + tokens: message.session.tokens + }); + this.graffiti.setSession(message.session); + } + }); + vscode.postMessage({ type: "ready" }); + this.showToast(`seed ${seed} - walk with WASD or arrows, M for menu`); + this.syncMonster(); + void this.loadMaterialImages(); + requestAnimationFrame((t) => this.frame(t)); + } + /** + * Loads the photo materials shipped under materials/ and patches them over + * the procedural atlas. Any file that is missing or fails to decode leaves + * its procedural tile in place. + */ + async loadMaterialImages() { + const uris = window.__BACKROOMS_MATERIALS__ ?? {}; + const load = (uri) => new Promise((resolve) => { + if (!uri) { + resolve(void 0); + return; + } + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => resolve(void 0); + image.src = uri; + }); + const [wallpaper, ceiling, carpet] = await Promise.all([ + load(uris.wallpaper), + load(uris.ceiling), + load(uris.carpet) + ]); + if (!wallpaper && !ceiling && !carpet) { + return; + } + this.renderer.applyMaterialImages({ wallpaper, ceiling, carpet }); + if (wallpaper) { + this.world.uniformWallpaper = true; + this.uniformWallpaper = true; + this.world.invalidateChunks(this.renderer); + } + } + toggleMenu() { + if (this.menu.isOpen) { + this.menu.close(); + } else { + this.input.releasePointer(); + this.menu.syncSettings(this.settings); + this.menu.syncStats(this.world.stats()); + this.menu.open(); + } + } + // The host may pick the seed (so its action response stays accurate); + // anything invalid or absent rolls a fresh one here. + relocate(seed) { + const next = typeof seed === "number" && Number.isFinite(seed) && seed > 0 ? Math.floor(seed) : randomSeed(); + this.rebuildWorld(next); + // Adopt the new seed as the effective setting; otherwise the config + // replayed on a reload still carries the old seed and immediately + // rebuilds away from the relocated world. + this.changeSetting("seed", next); + this.menu.close(); + this.showToast(`relocated to seed ${next}`); + } + rebuildWorld(seed) { + this.world.invalidateChunks(this.renderer); + this.graffiti.reset(this.renderer); + this.world = new World(seed); + this.world.furnitureEnabled = this.settings.furniture; + this.world.wallpaperShiftsEnabled = this.settings.wallpaperShifts; + this.world.setMaterial( + this.settings.materialPreset, + this.settings.materialHueShift, + this.settings.materialBrightness + ); + this.world.uniformWallpaper = this.uniformWallpaper; + this.px = 0.5; + this.py = 0.5; + this.syncMonster(true); + this.persist(); + this.menu.syncStats(this.world.stats()); + } + monsterConfig() { + return { + speed: this.settings.monsterSpeed, + spawnMinMs: this.settings.monsterSpawnMin * 6e4, + spawnMaxMs: this.settings.monsterSpawnMax * 6e4, + form: this.settings.monsterForm + }; + } + /** Creates, retunes, or removes the monster to match current settings. */ + syncMonster(rearm = false) { + if (!this.settings.monsterEnabled) { + this.monster = null; + this.renderer.clearDynamicMesh(); + return; + } + if (!this.monster || rearm) { + this.monster = new Monster(this.world, this.monsterConfig(), performance.now()); + } else { + this.monster.configure(this.monsterConfig()); + } + } + changeSetting(key, value) { + this.applySettings({ ...this.settings, [key]: value }); + vscode.postMessage({ type: "updateSetting", key, value }); + } + applySettings(settings) { + const previous = this.settings; + this.settings = settings; + this.film.grainEnabled = settings.filmGrain; + this.film.hudEnabled = settings.vhsHud; + this.film.tokenCounterEnabled = settings.copilotGhostWriter; + if (this.graffiti.enabled && !settings.copilotGhostWriter) { + this.graffiti.reset(this.renderer); + } + this.graffiti.enabled = settings.copilotGhostWriter; + this.input.mouseLookEnabled = settings.mouseLook; + this.renderer.fogDensity = 2.6 / (settings.renderDistance * settings.renderDistance); + if (previous.furniture !== settings.furniture || previous.wallpaperShifts !== settings.wallpaperShifts || previous.materialPreset !== settings.materialPreset || previous.materialHueShift !== settings.materialHueShift || previous.materialBrightness !== settings.materialBrightness) { + this.world.furnitureEnabled = settings.furniture; + this.world.wallpaperShiftsEnabled = settings.wallpaperShifts; + this.world.setMaterial(settings.materialPreset, settings.materialHueShift, settings.materialBrightness); + this.world.invalidateChunks(this.renderer); + } + this.syncMonster(previous.monsterEnabled !== settings.monsterEnabled); + if (settings.seed !== previous.seed && settings.seed !== 0 && settings.seed !== this.world.seed) { + this.rebuildWorld(settings.seed); + } + this.menu.syncSettings(settings); + } + showToast(text) { + this.toast.textContent = text; + this.toast.style.opacity = "1"; + clearTimeout(this.toastTimer); + this.toastTimer = setTimeout(() => { + this.toast.style.opacity = "0"; + }, 4200); + } + persist() { + vscode.setState({ seed: this.world.seed, px: this.px, py: this.py, yaw: this.yaw }); + } + frame(now) { + const dt = Math.min(0.05, (now - this.lastFrame) / 1e3 || 0.016); + this.lastFrame = now; + const t = now / 1e3; + let speed = 0; + if (!this.menu.isOpen) { + speed = this.step(dt); + if (this.monster) { + const event = this.monster.update(now, dt, this.px, this.py); + if (event === "spawned") { + this.showToast("the air changes. something else is in the halls."); + } else if (event === "caught") { + this.film.burst(now); + this.px = 0.5; + this.py = 0.5; + this.world.session.warpTo(0, 0); + this.persist(); + this.showToast("tape resumes somewhere familiar. it is still out there."); + } + } + } + if (this.monster?.isStalking) { + const mesh = this.monster.buildMesh(now, this.px, this.py); + this.renderer.setDynamicMesh(mesh.vertices, mesh.indices); + } else { + this.renderer.clearDynamicMesh(); + } + let shakeYaw = 0; + let shakePitch = 0; + let shakeRoll = 0; + let shakeUp = 0; + if (this.settings.cameraShake) { + const drift = 1 + speed * 1.6; + shakeYaw = (Math.sin(t * 0.9) * 6e-3 + Math.sin(t * 2.3 + 1.7) * 3e-3) * drift; + shakePitch = (Math.sin(t * 1.3 + 0.6) * 4e-3 + Math.sin(t * 3.1) * 2e-3) * drift; + shakeRoll = Math.sin(t * 0.7 + 2.1) * 4e-3 * drift + Math.sin(this.bobPhase) * 6e-3 * speed; + shakeUp = Math.sin(this.bobPhase * 2) * 0.014 * speed; + } + if (Math.random() < 15e-4 && this.flickerDipUntil < now) { + this.flickerDipUntil = now + 60 + Math.random() * 120; + } + let flicker = 1 + Math.sin(t * 11) * 0.012 + Math.sin(t * 47) * 8e-3; + if (this.flickerDipUntil > now) { + flicker *= 0.82; + } + const camera = { + x: this.px, + y: EYE_HEIGHT + shakeUp, + z: this.py, + yaw: this.yaw + shakeYaw, + pitch: this.pitch + shakePitch, + roll: shakeRoll, + fovY: 72 * Math.PI / 180 + }; + this.graffiti.update(now, this.world, this.px, this.py, this.yaw, this.renderer); + const chunks = this.world.updateChunks(this.px, this.py, this.settings.renderDistance, this.renderer); + this.renderer.draw(chunks, camera, flicker); + this.film.render(now); + if (now - this.lastPersist > 1500) { + this.lastPersist = now; + this.persist(); + } + requestAnimationFrame((next) => this.frame(next)); + } + /** Applies input to the player; returns normalized speed for bob effects. */ + step(dt) { + const input = this.input.state(); + const look = this.input.consumeLook(); + const turnSign = this.settings.invertTurn ? -1 : 1; + this.yaw -= (look.dx * 26e-4 + input.turn * 1.9 * dt) * turnSign; + this.pitch = clamp3(this.pitch - look.dy * 22e-4, -1.25, 1.25); + const rate = this.settings.moveSpeed * (input.running ? 1.7 : 1); + const forward = input.forward * (this.settings.invertForward ? -1 : 1); + const strafe = input.strafe * (this.settings.invertStrafe ? -1 : 1); + const fx = -Math.sin(this.yaw); + const fy = -Math.cos(this.yaw); + const rx = Math.cos(this.yaw); + const ry = -Math.sin(this.yaw); + const dx = (fx * forward + rx * strafe) * rate * dt; + const dy = (fy * forward + ry * strafe) * rate * dt; + if (dx === 0 && dy === 0) { + return 0; + } + const moved = this.world.moveResolved(this.px, this.py, dx, dy); + const actual = Math.hypot(moved.x - this.px, moved.y - this.py); + this.px = moved.x; + this.py = moved.y; + this.world.syncSession(this.px, this.py); + this.bobPhase += actual * 5.6; + return Math.min(1, actual / (rate * dt + 1e-6)); + } + }; + function randomSeed() { + return Math.floor(Math.random() * 999999) + 1; + } + function clamp3(v, lo, hi) { + return Math.min(hi, Math.max(lo, v)); + } + var app = document.getElementById("app"); + if (app) { + new Game(app); + } +})(); diff --git a/extensions/backrooms-canvas/materials/carpet.jpg b/extensions/backrooms-canvas/materials/carpet.jpg new file mode 100644 index 00000000..0a631716 Binary files /dev/null and b/extensions/backrooms-canvas/materials/carpet.jpg differ diff --git a/extensions/backrooms-canvas/materials/ceiling.jpg b/extensions/backrooms-canvas/materials/ceiling.jpg new file mode 100644 index 00000000..8d314ec3 Binary files /dev/null and b/extensions/backrooms-canvas/materials/ceiling.jpg differ diff --git a/extensions/backrooms-canvas/materials/wallpaper.jpg b/extensions/backrooms-canvas/materials/wallpaper.jpg new file mode 100644 index 00000000..9b0fd07d Binary files /dev/null and b/extensions/backrooms-canvas/materials/wallpaper.jpg differ diff --git a/extensions/backrooms-canvas/package-lock.json b/extensions/backrooms-canvas/package-lock.json new file mode 100644 index 00000000..f7bfc585 --- /dev/null +++ b/extensions/backrooms-canvas/package-lock.json @@ -0,0 +1,446 @@ +{ + "name": "backrooms-canvas", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backrooms-canvas", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@github/copilot-sdk": "latest" + } + }, + "node_modules/@github/copilot": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.71.tgz", + "integrity": "sha512-F3axBi+sXSLYDJbxCBW36bM6MYKNC2rlyAf3Ivo/MjiHKKJW7j5AmaR1IRYS9Gt8r9mxOwWFM1cJFA+CuLaR8g==", + "dependencies": { + "detect-libc": "^2.1.2" + }, + "bin": { + "copilot": "npm-loader.js" + }, + "optionalDependencies": { + "@github/copilot-darwin-arm64": "1.0.71", + "@github/copilot-darwin-x64": "1.0.71", + "@github/copilot-linux-arm64": "1.0.71", + "@github/copilot-linux-x64": "1.0.71", + "@github/copilot-linuxmusl-arm64": "1.0.71", + "@github/copilot-linuxmusl-x64": "1.0.71", + "@github/copilot-win32-arm64": "1.0.71", + "@github/copilot-win32-x64": "1.0.71" + } + }, + "node_modules/@github/copilot-darwin-arm64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.71.tgz", + "integrity": "sha512-mEWzyqbqRAWgyU7i2uuSRoVPx/TwaFQX0nZmw0bc30aJ0BnO7cy2kYQyCHw8ykmf/tfxT0xauZ6k0BOFmWizzQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-arm64": "copilot" + } + }, + "node_modules/@github/copilot-darwin-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.71.tgz", + "integrity": "sha512-Md9yEg406OBVBx3w4PeEj62TubulVLBcHleqmCoOoUmPgUxPZotUbrqz3rtbzADbXfrrD7JWvVsbd2UiNL194w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "copilot-darwin-x64": "copilot" + } + }, + "node_modules/@github/copilot-linux-arm64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.71.tgz", + "integrity": "sha512-ykLJYOqBj3jRB5IJCDugLClAqbr7DmtTbUjlNY7+Jdq/n6i+d7xUQGclf1IWL5gnxbGQVAf+zkToD+sRM389Kg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linux-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.71.tgz", + "integrity": "sha512-pC0FNHG+BBwZd6yZlM85kkAGN+uJhM6o+THi76N2GnnSxmw7+remb1mvYxdgRVbdCm+LBUIbCKRWJLuMwrfb6A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linux-x64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-arm64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.71.tgz", + "integrity": "sha512-hBmDljFTjacxqZTasCEy43H8EIzuXB/hHEBBCMFjhB9J00nIxsO6Dh0woTifKpx7knTYZdpTjjca3D0pAoZlUA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-arm64": "copilot" + } + }, + "node_modules/@github/copilot-linuxmusl-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.71.tgz", + "integrity": "sha512-CfTXU8pa5dxRz22xQzoi3TiG1PJo9+WR8PRDiPSdkIBSyPJ1NvX87DJmfXjTgeAfR+wkjt/p0keDCaBBVhNmUA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "bin": { + "copilot-linuxmusl-x64": "copilot" + } + }, + "node_modules/@github/copilot-sdk": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.7.tgz", + "integrity": "sha512-dgCFCPfxWUkrgclQbrm7WCFzTf5RnJHsK1Lqsc3KjPBbDLPutJT0qIGg3xJ0ZELLyX0icg3TOmVczhR4HdwHxw==", + "dependencies": { + "@github/copilot": "^1.0.71", + "koffi": "^3.1.0", + "vscode-jsonrpc": "^8.2.1", + "zod": "^4.3.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@github/copilot-win32-arm64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.71.tgz", + "integrity": "sha512-+HI1DokixXhHUahj06Fw67ZAigBuXKC58BFma4UJOGrQsDgwOSbqeTQHCw6vuymzjKlg3sactfsCUTaefkjscQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-arm64": "copilot.exe" + } + }, + "node_modules/@github/copilot-win32-x64": { + "version": "1.0.71", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.71.tgz", + "integrity": "sha512-02kXOBd9CwBbCaztuf71WYWn+uGapCuiaasomN4tcMH3HBVZ4gi3J0ZUoRcgcS80xh81uQyeBHbnUKzb/RE/9A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "bin": { + "copilot-win32-x64": "copilot.exe" + } + }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.1.tgz", + "integrity": "sha512-+Dl0zQDh1Wb55AWOn9hp7K30qgkODvrvN+ZNkFOh81Q0oFX/rpJQtocgjAuYk2zFAcajSeVDumkcHMPwnKSXzA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.1.tgz", + "integrity": "sha512-cDFAKn1qdZBFLrp7dAc9QUDw3l4xAhTJbOdPWWb0LxssVicUdHcRCLZGrDsmPW2tpH6LGNNeLgqRpAoD2Mo8iA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.1.tgz", + "integrity": "sha512-zaP7FJISI/scQW9Wa5QicY3a09WmtKBWSbmC+5nfCqPzwWe7Hx2so74Er7mPsDfCiMMR0Ya+evKbJQDkfyXicg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.1.tgz", + "integrity": "sha512-7GejVb688TLM8rbjfc0oezJrATxZc0dn801xWEDJekN2DgmRXu7HquGqWQ6z3NeSq7ZxEggz4T3xtlbCysQapA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.1.tgz", + "integrity": "sha512-XLiCFP9OFCyOoGTjAimtDKLhzhfo34WcP1ShVWxRzNCWDGjfz8BYjwd69cp/cDSUXZbxamqs4+/6vmkePq9wxA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.1.tgz", + "integrity": "sha512-HA9xINK7G4dRAkpfnBWD9VfuyIBgW1SuK+KPHjksUwRMOnhgqP8J/JqgrAzdzcDiefGBkqEacIP776OUwz7knQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.1.tgz", + "integrity": "sha512-jG7IFytmP8K5Qtbx0ro0ZeuX3JjSsLxmYhq+nmXDdrtOAlxIsWGynuiDLS6Jk3vOchVii2m6Y2f/L3GLG2fG5A==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.1.tgz", + "integrity": "sha512-CIsT1cNnih8FuU52Me/IVlJBpH28SQfoDeYPctJswgJzaARktusF7m4MUbtR1PBDjuquCVM4/vFyNdOzfPonvA==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.1.tgz", + "integrity": "sha512-9D6RmqeKsSvs3U6jILJU9PcAjMwKKyn7yLxNBb5k6z9PCoUoGJ3/BrhXAX0qjrLLwEiIpP/hS/40RuXvH8Lc3Q==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.1.tgz", + "integrity": "sha512-pyTcX5fePeYbt7TZAwRby69wdlRx3PT+g15ra5IYdat/Pgh3qAKEYeZ+uu7WpPGOy43p/oSRqqZoa2kORzozlA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.1.tgz", + "integrity": "sha512-iPnPzvG2HOfdzaiG1drdkt86sAqmTPDv9mAf+5gL7mRzkeeQC88EVGboRy7eXwdXn7R+v0ntA3iQxdHrBn6yXw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.1.tgz", + "integrity": "sha512-/Xqc3R0SVoMCYjMPZnJ9bULtRo364+dKmnQhfDrI83tSpxUHRw7HRNf12vBeL+hPgKxSBjtMpWfQ/ZIyVyLFag==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.1.tgz", + "integrity": "sha512-JhqHauEwQvdcWUERxrV5HH/DT9W7hY1A1eU6/o8tB+yck+D3kt5elpRDBt9KjpW6h+vHPy3V0sjDvO0CXyabTA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.1.tgz", + "integrity": "sha512-ZRuyYmlGS/rCc966qqs0qREXDW4FRdul7rDF1VgSWHbVmdc196PUgUT+blq/GjZgTwqzeEXtMRgM+cU8krHjvA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.1.tgz", + "integrity": "sha512-KqHPmvj6QILhNyI/To8QSihHsijeVGIYYPBOUnXEpcnH2LuLbargY4Hd6dDeTN3Z90uUUxN+1FWz1UnhVzFOiA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/koffi": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.1.tgz", + "integrity": "sha512-mRX6AMeeKCxSOeOopqAcLAl5jcNvge7NAG8l7rF/8gGJATI0tdHFYjteIdE0mGOtWdsrJOij+PjnP8Q9c1gwgA==", + "hasInstallScript": true, + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.1.1", + "@koromix/koffi-darwin-x64": "3.1.1", + "@koromix/koffi-freebsd-arm64": "3.1.1", + "@koromix/koffi-freebsd-ia32": "3.1.1", + "@koromix/koffi-freebsd-x64": "3.1.1", + "@koromix/koffi-linux-arm64": "3.1.1", + "@koromix/koffi-linux-ia32": "3.1.1", + "@koromix/koffi-linux-loong64": "3.1.1", + "@koromix/koffi-linux-riscv64": "3.1.1", + "@koromix/koffi-linux-x64": "3.1.1", + "@koromix/koffi-openbsd-ia32": "3.1.1", + "@koromix/koffi-openbsd-x64": "3.1.1", + "@koromix/koffi-win32-arm64": "3.1.1", + "@koromix/koffi-win32-ia32": "3.1.1", + "@koromix/koffi-win32-x64": "3.1.1" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", + "integrity": "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/extensions/backrooms-canvas/package.json b/extensions/backrooms-canvas/package.json new file mode 100644 index 00000000..abb1d48d --- /dev/null +++ b/extensions/backrooms-canvas/package.json @@ -0,0 +1,20 @@ +{ + "name": "backrooms-canvas", + "version": "1.0.0", + "main": "extension.mjs", + "author": "John Haugabook", + "license": "MIT", + "type": "module", + "dependencies": { + "@github/copilot-sdk": "latest" + }, + "description": "Wander an endless first-person backrooms in a Copilot canvas while agents work; their status ghost-writes on the walls.", + "keywords": [ + "backrooms", + "copilot-canvas", + "interactive-canvas", + "first-person", + "procedural-generation", + "session-breaks" + ] +} diff --git a/extensions/connector-namespaces/.github/plugin/plugin.json b/extensions/connector-namespaces/.github/plugin/plugin.json index a75cb84c..e9fbc015 100644 --- a/extensions/connector-namespaces/.github/plugin/plugin.json +++ b/extensions/connector-namespaces/.github/plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "connector-namespaces", - "description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.", - "version": "1.1.2", + "description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.", + "version": "1.2.0", "author": { "name": "Alex Yang", "url": "https://github.com/alexyaang" diff --git a/extensions/connector-namespaces/README.md b/extensions/connector-namespaces/README.md index f7346247..66c53489 100644 --- a/extensions/connector-namespaces/README.md +++ b/extensions/connector-namespaces/README.md @@ -1,84 +1,80 @@ -# MCP Connectors — Copilot CLI Canvas Extension +# MCP Connectors -A GitHub Copilot CLI **canvas extension** that lets you browse and add MCP -connectors from an Azure **Connector Namespace** directly inside a Copilot CLI -session. Search by name or category, sign in to a connector, then restart the -session to make its tools available to the agent. +A GitHub Copilot app canvas extension for discovering and connecting hosted MCP +servers from [Azure Connector Namespace](https://learn.microsoft.com/en-us/azure/connector-namespace/connector-namespace-overview). +It brings the Microsoft and partner connector catalog, guided browser sign-in, +and connected-server management into the Copilot side panel. -> The canvas talks to public Azure Resource Manager (`management.azure.com`) -> using the signed-in Azure CLI account. The extension does not register its own -> Entra application or persist Azure credentials. +## Features -## Prerequisites - -- **GitHub Copilot CLI** (the host that loads canvas extensions). -- **Azure CLI**, signed in with `az login`. The extension asks Azure CLI for a - short-lived ARM access token and refreshes it through the same broker. -- **An Azure subscription with a Connector Namespace** — resource type - `Microsoft.Web/connectorGateways` (API version `2026-05-01-preview`). This is - a preview resource provider; you must have access to it for the catalog to - load. Without it the extension installs fine but has nothing to show. +- **Connector catalog** - browse and search Microsoft and partner MCP servers + available in your namespace. +- **Guided Azure setup** - sign in from the canvas, then choose a subscription + and Connector Namespace. +- **Browser-based connection flow** - complete each connector's authentication + or consent without leaving the setup experience. +- **My MCPs** - see which servers are connected and ready to add to Copilot. +- **Namespace playground** - open any connected server in the Connector + Namespace playground with **Sandbox**. +- **Persistent namespace selection** - retain the selected namespace while Azure + tokens remain in memory and sign-in is requested again after a restart. ## Install -Install it from the public Awesome Copilot repository: +Open the GitHub Copilot app, go to **Settings > Plugins**, search for +`connector-namespaces`, and select **Install**. -``` -install_extension https://github.com/github/awesome-copilot/tree/main/extensions/connector-namespaces -``` +You can also open the +[MCP Connectors gallery page](https://awesome-copilot.github.com/extension/connector-namespaces/) +and select **Install in GitHub Copilot app**. -For a reproducible install, swap `main` for a reviewed commit SHA from this -repository. +## Requirements -The destination **scope** is chosen at install time: +- Access to an Azure subscription with a Connector Namespace. If you do not + have one, follow the + [Connector Namespace creation guide](https://learn.microsoft.com/en-us/azure/connector-namespace/create-connector-namespace). +- Permission to view the namespace and create its connections and hosted MCP + server configurations. +- A browser for Microsoft Entra sign-in and connector consent. -- **user** (default) — installs globally for you at - `$COPILOT_HOME/extensions/connector-namespaces/`. The usual choice for a - personal tool. -- **project** — installs into the current repo. -- **session** — scoped to a single CLI session. +Connector Namespace is currently an Azure preview service and availability can +vary by region. ## Usage -1. Open the **MCP Connectors** canvas from Copilot CLI. -2. The canvas loads subscriptions from your signed-in Azure CLI account. Pick an - Azure **subscription** and a **Connector Namespace**. The choice is saved for - future sessions (change it any time via **Change namespace**). -3. Browse or filter the connector catalog, then **Connect**. A browser tab - opens for Microsoft sign-in; complete it and the canvas updates on its own. -4. Connected connectors move into **My MCPs**. Use **Sandbox** on a tile to open - that server directly in the namespace MCP playground. -5. Restart the Copilot CLI session so the agent can load the connected tools. +1. Open the **MCP Connectors** canvas in the GitHub Copilot app. +2. Select **Sign in to Azure** and complete Microsoft Entra authentication in + your browser. +3. Choose an Azure subscription and Connector Namespace. +4. Browse or search the catalog, then select **Connect** on an MCP server. +5. Complete the connector's sign-in or consent flow when prompted. +6. Confirm the server appears under **My MCPs**. +7. Restart the GitHub Copilot app so the new tools become available to the + agent. -The extension registers the native `connector_namespaces_open_playground` tool, -so GitHub Copilot can open a named connector from **My MCPs** without installing -an additional Agent Skill. +Use **Sandbox** on a connected server to inspect it in the Connector Namespace +playground. Use **Change namespace** to switch subscriptions or namespaces. -## How it works +## Authentication and privacy -- `extension.mjs` — entry point; declares the canvas, `open_sandbox` action, and - native `connector_namespaces_open_playground` tool. -- `server.mjs` — a loopback HTTP server (bound to `127.0.0.1` only) that serves - the canvas UI and the JSON/OAuth endpoints the iframe calls. -- `armClient.mjs` — thin ARM client (token brokered by Azure CLI, public ARM - base only, SSRF-guarded path segments). -- `catalog.mjs` — fetches and curates the connector list for a namespace. -- `install.mjs` — the connect/install pipeline (managed-API connection, consent, - rollback on cancel, and native HTTPS MCP config registration). -- `renderer.mjs` — all canvas HTML/CSS/client JS. -- `sandbox.mjs` — builds namespace playground links and resolves named My MCPs. -- `state.mjs` — saved namespace and connector state. +Azure sign-in and connector sign-in are separate: -## Privacy & security +- **Azure sign-in** lets the canvas discover and manage Connector Namespace + resources. Access and refresh tokens remain in the extension process and are + never written to extension files. Reloading the extension or restarting the + app requires Azure sign-in again. The selected namespace coordinates are + retained in + `~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json` so + the canvas can explain that the namespace is still linked and return directly + to its connectors after sign-in. +- **Connector sign-in** grants an individual MCP server access to its backing + service. The resulting connection is managed by Connector Namespace. -- ARM tokens come from `az account get-access-token`, stay in process memory, - and are never logged or written by the extension. Azure CLI owns sign-in and - credential storage. -- All servers bind to loopback (`127.0.0.1`) and are never exposed externally. -- ARM requests go only to `https://management.azure.com/`; path segments are - validated to prevent SSRF-style host smuggling. -- The minted gateway API key is stored in the selected Copilot MCP config and - sent to its validated HTTPS endpoint as the `X-API-Key` header. +The canvas serves its interface from loopback only (`127.0.0.1`). Azure +management requests are restricted to `https://management.azure.com/`. +The gateway API key that lets Copilot reach a connected server is stored in the +user-scoped GitHub Copilot MCP configuration and sent only to that server's +configured HTTPS endpoint. ## License diff --git a/extensions/connector-namespaces/armClient.mjs b/extensions/connector-namespaces/armClient.mjs index dc0f9fc4..c4ef7f4a 100644 --- a/extensions/connector-namespaces/armClient.mjs +++ b/extensions/connector-namespaces/armClient.mjs @@ -1,62 +1,17 @@ -// ARM API client — fetches real connector data with Azure CLI credentials. +// ARM API client — fetches real connector data with interactive Azure credentials. -import { exec, execFile } from "node:child_process"; import { constants as fsConstants, promises as fs } from "node:fs"; -import { homedir, platform } from "node:os"; -import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path"; -import { promisify } from "node:util"; +import { platform } from "node:os"; +import { basename, isAbsolute, join, resolve, sep } from "node:path"; +import { getToken } from "./auth.mjs"; + +export { getToken }; const API_VERSION = "2026-05-01-preview"; const RG_API_VERSION = "2021-04-01"; const MSI_API_VERSION = "2023-01-31"; const SUBS_API_VERSION = "2020-01-01"; -const execFileAsync = promisify(execFile); -const execAsync = promisify(exec); -const ARM_RESOURCE = "https://management.azure.com/"; -const EXPIRY_SKEW_MS = 5 * 60 * 1000; -const LEGACY_AUTH_CACHE = join( - process.env.COPILOT_HOME || join(homedir(), ".copilot"), - "extensions", - "connector-namespaces", - "artifacts", - "auth-cache.json", -); - -let s_auth = null; // { token, expiresAt } -let s_authInFlight = null; -let s_legacyAuthCacheRemoved = false; - -export function parseAzureCliToken(stdout) { - let data; - try { - data = JSON.parse(stdout); - } catch { - throw new Error("Azure CLI returned invalid token JSON."); - } - const token = data?.accessToken; - const epochSeconds = Number(data?.expires_on); - const expiresAt = Number.isFinite(epochSeconds) && epochSeconds > 0 - ? epochSeconds * 1000 - : Date.parse(data?.expiresOn); - if (typeof token !== "string" || token.length === 0 || !Number.isFinite(expiresAt)) { - throw new Error("Azure CLI returned an incomplete ARM token."); - } - return { token, expiresAt }; -} - -async function removeLegacyAuthCache() { - if (s_legacyAuthCacheRemoved) return; - try { - await fs.unlink(LEGACY_AUTH_CACHE); - } catch (error) { - if (error?.code !== "ENOENT") { - throw new Error(`Could not remove the legacy connector credential cache at ${LEGACY_AUTH_CACHE}: ${error.message}`); - } - } - s_legacyAuthCacheRemoved = true; -} - function windowsSystemExecutable(name) { const systemRoot = process.env.SystemRoot; if (systemRoot && isAbsolute(systemRoot)) return join(systemRoot, "System32", name); @@ -92,29 +47,6 @@ async function trustedExecutablePath(path, expectedName, workspaceRoot = process return candidate; } -async function resolveWindowsAzureCli() { - const trustedCwd = homedir(); - const { stdout } = await execFileAsync( - windowsSystemExecutable("where.exe"), - ["az.cmd"], - { cwd: trustedCwd, encoding: "utf8", windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 }, - ); - for (const path of stdout.split(/\r?\n/).map((line) => line.trim())) { - if (/[%]/.test(path)) continue; - const candidate = await trustedExecutablePath(path, "az.cmd"); - if (candidate) return candidate; - } - throw new Error("Azure CLI was not found outside the current workspace."); -} - -export async function resolvePosixAzureCli(pathValue = process.env.PATH || "", workspaceRoot = process.cwd()) { - for (const directory of pathValue.split(delimiter)) { - const candidate = await trustedExecutablePath(resolve(directory || workspaceRoot, "az"), "az", workspaceRoot); - if (candidate) return candidate; - } - throw new Error("Azure CLI was not found outside the current workspace."); -} - export async function resolveSystemExecutable(name, workspaceRoot = process.cwd()) { const candidates = platform() === "win32" ? [windowsSystemExecutable(name)] @@ -126,41 +58,6 @@ export async function resolveSystemExecutable(name, workspaceRoot = process.cwd( throw new Error(`Could not resolve the trusted system executable ${name}.`); } -async function acquireToken() { - await removeLegacyAuthCache(); - try { - const windows = platform() === "win32"; - const azureCli = windows ? await resolveWindowsAzureCli() : await resolvePosixAzureCli(); - const options = { cwd: homedir(), encoding: "utf8", windowsHide: true, timeout: 60_000, maxBuffer: 1024 * 1024 }; - const { stdout } = windows - ? await execAsync( - `"${azureCli}" account get-access-token --resource https://management.azure.com/ --output json --only-show-errors`, - { ...options, shell: windowsSystemExecutable("cmd.exe") }, - ) - : await execFileAsync( - azureCli, - ["account", "get-access-token", "--resource", ARM_RESOURCE, "--output", "json", "--only-show-errors"], - options, - ); - s_auth = parseAzureCliToken(stdout); - return s_auth.token; - } catch (error) { - const detail = String(error?.stderr || error?.message || "").trim(); - throw new Error( - `Azure CLI authentication failed. Install Azure CLI and run "az login" before opening the canvas.${detail ? ` ${detail}` : ""}`, - ); - } -} - -export async function getToken() { - if (s_auth && s_auth.expiresAt - EXPIRY_SKEW_MS > Date.now()) return s_auth.token; - if (s_authInFlight) return s_authInFlight; - s_authInFlight = acquireToken().finally(() => { - s_authInFlight = null; - }); - return s_authInFlight; -} - /** * List all enabled Azure subscriptions the user has access to. */ @@ -170,9 +67,13 @@ export async function getToken() { let s_subsCache = null; // { subs, expiresAt } const SUBS_TTL_MS = 30 * 60 * 1000; -export async function listSubscriptions() { +export function invalidateSubscriptionsCache() { + s_subsCache = null; +} + +export async function listSubscriptions({ forceRefresh = false } = {}) { const now = Date.now(); - if (s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs; + if (!forceRefresh && s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs; const token = await getToken(); const url = `https://management.azure.com/subscriptions?api-version=${SUBS_API_VERSION}`; const raw = await paginateAll(url, token); diff --git a/extensions/connector-namespaces/auth.mjs b/extensions/connector-namespaces/auth.mjs new file mode 100644 index 00000000..c0eb5fd6 --- /dev/null +++ b/extensions/connector-namespaces/auth.mjs @@ -0,0 +1,236 @@ +import { randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { InteractiveBrowserCredential } from "@azure/identity"; + +export const ARM_SCOPE = "https://management.azure.com/.default"; + +const TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000; +const SIGN_IN_SESSION_TTL_MS = 10 * 60 * 1000; +const AUTH_STORAGE_DIR = join( + process.env.COPILOT_HOME || join(homedir(), ".copilot"), + "extensions", + "connector-namespaces", + "artifacts", +); +const AUTH_RECORD_FILE = join(AUTH_STORAGE_DIR, "azure-auth-record.json"); +const LEGACY_AUTH_CACHE = join(AUTH_STORAGE_DIR, "auth-cache.json"); + +let legacyAuthArtifactsRemoved = false; + +async function removeLegacyAuthArtifacts() { + if (legacyAuthArtifactsRemoved) return; + for (const path of [AUTH_RECORD_FILE, LEGACY_AUTH_CACHE]) { + try { + await fs.unlink(path); + } catch (error) { + if (error?.code !== "ENOENT") { + throw new Error(`Could not remove the legacy connector authentication file at ${path}: ${error.message}`); + } + } + } + legacyAuthArtifactsRemoved = true; +} + +export class ConnectorAuthenticationRequiredError extends Error { + constructor(message = "Sign in to Azure to continue.", options) { + super(message, options); + this.name = "ConnectorAuthenticationRequiredError"; + this.code = "authentication_required"; + } +} + +export function isAuthenticationRequiredError(error) { + return error instanceof ConnectorAuthenticationRequiredError + || error?.code === "authentication_required" + || error?.name === "AuthenticationRequiredError"; +} + +function credentialFactory(options) { + return new InteractiveBrowserCredential(options); +} + +function hasUsableToken(accessToken, now) { + return !!accessToken?.token + && Number.isFinite(accessToken.expiresOnTimestamp) + && accessToken.expiresOnTimestamp - TOKEN_EXPIRY_SKEW_MS > now; +} + +function errorDetail(error) { + return String(error?.message || error || "Azure sign-in failed.").slice(0, 400); +} + +export class InteractiveAuthBroker { + constructor({ + createCredential = credentialFactory, + createSessionId = randomUUID, + cleanupLegacyCredentials = async () => {}, + now = Date.now, + scope = ARM_SCOPE, + } = {}) { + this.createCredential = createCredential; + this.createSessionId = createSessionId; + this.cleanupLegacyCredentials = cleanupLegacyCredentials; + this.now = now; + this.scope = scope; + this.credential = null; + this.accessToken = null; + this.cleanupInFlight = null; + this.tokenInFlight = null; + this.sessions = new Map(); + } + + createInteractiveCredential() { + return this.createCredential({ + redirectUri: "http://localhost", + disableAutomaticAuthentication: true, + }); + } + + ensureLegacyCredentialsRemoved() { + if (!this.cleanupInFlight) { + const cleanup = Promise.resolve() + .then(() => this.cleanupLegacyCredentials()) + .catch((error) => { + if (this.cleanupInFlight === cleanup) this.cleanupInFlight = null; + throw error; + }); + this.cleanupInFlight = cleanup; + } + return this.cleanupInFlight; + } + + pruneSessions() { + const cutoff = this.now() - SIGN_IN_SESSION_TTL_MS; + for (const [sessionId, session] of this.sessions) { + if (session.createdAt >= cutoff) continue; + session.status = "cancelled"; + session.abortController.abort(); + this.sessions.delete(sessionId); + } + } + + startSignIn() { + this.pruneSessions(); + const sessionId = this.createSessionId(); + const abortController = new AbortController(); + let credential; + try { + credential = this.createInteractiveCredential(); + } catch (error) { + return { ok: false, reason: "identity_unavailable", error: errorDetail(error) }; + } + + const session = { + abortController, + createdAt: this.now(), + error: "", + status: "pending", + }; + this.sessions.set(sessionId, session); + + session.promise = Promise.resolve() + .then(async () => { + await this.ensureLegacyCredentialsRemoved(); + const authenticationRecord = await credential.authenticate( + this.scope, + { abortSignal: abortController.signal }, + ); + if (!authenticationRecord) { + throw new Error("Azure identity did not return an authentication record."); + } + const accessToken = await credential.getToken(this.scope, { abortSignal: abortController.signal }); + if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) { + throw new Error("Azure identity returned an incomplete ARM access token."); + } + if (session.status !== "pending" || this.sessions.get(sessionId) !== session) return; + this.credential = credential; + this.accessToken = accessToken; + session.status = "done"; + }) + .catch((error) => { + if (session.status !== "pending") return; + session.status = abortController.signal.aborted ? "cancelled" : "error"; + if (session.status === "error") session.error = errorDetail(error); + }); + + return { ok: true, sessionId, mode: "interactive" }; + } + + getSignInStatus(sessionId) { + this.pruneSessions(); + const session = this.sessions.get(sessionId); + if (!session) return { ok: false, status: "unknown" }; + if (session.status === "pending") return { ok: true, status: "pending", mode: "interactive" }; + + if (session.status === "done") return { ok: true, status: "done" }; + if (session.status === "cancelled") return { ok: true, status: "cancelled" }; + return { ok: false, status: "error", error: session.error || "Azure sign-in failed." }; + } + + cancelSignIn(sessionId) { + const session = this.sessions.get(sessionId); + if (!session || session.status !== "pending") return { ok: true }; + session.status = "cancelled"; + session.abortController.abort(); + return { ok: true }; + } + + async getToken() { + await this.ensureLegacyCredentialsRemoved(); + if (hasUsableToken(this.accessToken, this.now())) return this.accessToken.token; + if (this.tokenInFlight) return this.tokenInFlight; + + const request = (async () => { + let credential = this.credential; + if (!credential) { + try { + credential = this.createInteractiveCredential(); + this.credential = credential; + } catch (error) { + if (isAuthenticationRequiredError(error)) { + throw new ConnectorAuthenticationRequiredError( + "Sign in to Azure to continue.", + { cause: error }, + ); + } + throw error; + } + } + try { + const accessToken = await credential.getToken(this.scope); + if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) { + throw new Error("Azure identity returned an incomplete ARM access token."); + } + this.accessToken = accessToken; + return accessToken.token; + } catch (error) { + if (!isAuthenticationRequiredError(error)) throw error; + if (this.credential === credential) { + this.credential = null; + this.accessToken = null; + } + throw new ConnectorAuthenticationRequiredError( + "Sign in to Azure to continue.", + { cause: error }, + ); + } + })(); + this.tokenInFlight = request; + try { + return await request; + } finally { + if (this.tokenInFlight === request) this.tokenInFlight = null; + } + } +} + +export const interactiveAuth = new InteractiveAuthBroker({ + cleanupLegacyCredentials: removeLegacyAuthArtifacts, +}); + +export const startSignIn = () => interactiveAuth.startSignIn(); +export const getSignInStatus = (sessionId) => interactiveAuth.getSignInStatus(sessionId); +export const cancelSignIn = (sessionId) => interactiveAuth.cancelSignIn(sessionId); +export const getToken = () => interactiveAuth.getToken(); diff --git a/extensions/connector-namespaces/auth.test.mjs b/extensions/connector-namespaces/auth.test.mjs new file mode 100644 index 00000000..33278646 --- /dev/null +++ b/extensions/connector-namespaces/auth.test.mjs @@ -0,0 +1,302 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { + ConnectorAuthenticationRequiredError, + InteractiveAuthBroker, +} from "./auth.mjs"; + +function accessToken(token = "token", expiresOnTimestamp = 2_000_000_000_000) { + return { token, expiresOnTimestamp }; +} + +function authenticationRecord(username = "user@example.com") { + return { + authority: "login.microsoftonline.com", + homeAccountId: "home-account", + clientId: "client-id", + tenantId: "tenant-id", + username, + }; +} + +test("ARM token requests require an explicit browser sign-in", async () => { + let credentialOptions; + const broker = new InteractiveAuthBroker({ + createCredential(options) { + credentialOptions = options; + return { + async getToken() { + const error = new Error("No cached account found."); + error.name = "AuthenticationRequiredError"; + throw error; + }, + }; + }, + }); + + await assert.rejects( + broker.getToken(), + (error) => error instanceof ConnectorAuthenticationRequiredError + && error.code === "authentication_required", + ); + assert.deepEqual(credentialOptions, { + redirectUri: "http://localhost", + disableAutomaticAuthentication: true, + }); +}); + +test("interactive sign-in reports pending then done and keeps the ARM token in memory", async () => { + let credentialOptions; + let authenticateOptions; + const credential = { + async authenticate(scope, options) { + assert.equal(scope, "https://management.azure.com/.default"); + authenticateOptions = options; + return authenticationRecord(); + }, + async getToken(scope, options) { + assert.equal(scope, "https://management.azure.com/.default"); + assert.equal(options.abortSignal, authenticateOptions.abortSignal); + return accessToken(); + }, + }; + const broker = new InteractiveAuthBroker({ + createCredential(options) { + credentialOptions = options; + return credential; + }, + createSessionId: () => "signin-session", + now: () => 1_000, + }); + + const started = broker.startSignIn(); + assert.deepEqual(started, { + ok: true, + sessionId: "signin-session", + mode: "interactive", + }); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { + ok: true, + status: "pending", + mode: "interactive", + }); + + await broker.sessions.get(started.sessionId).promise; + + assert.deepEqual(credentialOptions, { + redirectUri: "http://localhost", + disableAutomaticAuthentication: true, + }); + assert.equal(authenticateOptions.abortSignal.aborted, false); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" }); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" }); + assert.deepEqual(broker.cancelSignIn(started.sessionId), { ok: true }); + assert.equal(authenticateOptions.abortSignal.aborted, false); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" }); + assert.equal(await broker.getToken(), "token"); +}); + +test("a new broker requires browser sign-in after the extension reloads", async () => { + let authenticateCalls = 0; + let createCredentialCalls = 0; + const createCredential = (options) => { + createCredentialCalls++; + assert.deepEqual(options, { + redirectUri: "http://localhost", + disableAutomaticAuthentication: true, + }); + let signedIn = false; + return { + async authenticate() { + authenticateCalls++; + signedIn = true; + return authenticationRecord(); + }, + async getToken() { + if (signedIn) return accessToken("memory-token"); + const error = new Error("No cached account found."); + error.name = "AuthenticationRequiredError"; + throw error; + }, + }; + }; + const signedInBroker = new InteractiveAuthBroker({ + createCredential, + createSessionId: () => "persist-session", + now: () => 1_000, + }); + const started = signedInBroker.startSignIn(); + await signedInBroker.sessions.get(started.sessionId).promise; + assert.equal(authenticateCalls, 1); + assert.equal(await signedInBroker.getToken(), "memory-token"); + + const restartedBroker = new InteractiveAuthBroker({ + createCredential, + now: () => 1_000, + }); + await assert.rejects(restartedBroker.getToken(), ConnectorAuthenticationRequiredError); + assert.equal(authenticateCalls, 1); + assert.equal(createCredentialCalls, 2); +}); + +test("concurrent first-time token requests share credential acquisition", async () => { + let releaseToken; + const tokenReady = new Promise((resolve) => { + releaseToken = resolve; + }); + let createCredentialCalls = 0; + let tokenCalls = 0; + const broker = new InteractiveAuthBroker({ + createCredential: () => { + createCredentialCalls++; + return { + async getToken() { + tokenCalls++; + await tokenReady; + return accessToken("shared-token"); + }, + }; + }, + }); + + const firstRequest = broker.getToken(); + const secondRequest = broker.getToken(); + await new Promise((resolve) => setImmediate(resolve)); + releaseToken(); + + assert.deepEqual( + await Promise.all([firstRequest, secondRequest]), + ["shared-token", "shared-token"], + ); + assert.equal(createCredentialCalls, 1); + assert.equal(tokenCalls, 1); +}); + +test("cancelling sign-in aborts the credential request", async () => { + let abortSignal; + const credential = { + authenticate(_scope, options) { + abortSignal = options.abortSignal; + return new Promise((resolve, reject) => { + options.abortSignal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }); + }, + async getToken() { + const error = new Error("No cached account found."); + error.name = "AuthenticationRequiredError"; + throw error; + }, + }; + const broker = new InteractiveAuthBroker({ + createCredential: () => credential, + createSessionId: () => "cancel-session", + now: () => 1_000, + }); + + const started = broker.startSignIn(); + const pending = broker.sessions.get(started.sessionId).promise; + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(abortSignal.aborted, false); + + assert.deepEqual(broker.cancelSignIn(started.sessionId), { ok: true }); + await pending; + + assert.equal(abortSignal.aborted, true); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "cancelled" }); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "cancelled" }); + await assert.rejects(broker.getToken(), ConnectorAuthenticationRequiredError); +}); + +test("sign-in failures are surfaced through the status endpoint contract", async () => { + const broker = new InteractiveAuthBroker({ + createCredential: () => ({ + async authenticate() { + throw new Error("browser launch failed"); + }, + async getToken() { + throw new Error("unreachable"); + }, + }), + createSessionId: () => "failed-session", + now: () => 1_000, + }); + + const started = broker.startSignIn(); + await broker.sessions.get(started.sessionId).promise; + + assert.deepEqual(broker.getSignInStatus(started.sessionId), { + ok: false, + status: "error", + error: "browser launch failed", + }); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { + ok: false, + status: "error", + error: "browser launch failed", + }); + assert.deepEqual(broker.cancelSignIn(started.sessionId), { ok: true }); + assert.deepEqual(broker.getSignInStatus(started.sessionId), { + ok: false, + status: "error", + error: "browser launch failed", + }); +}); + +test("legacy credential cleanup retries after a transient failure", async () => { + let cleanupCalls = 0; + const broker = new InteractiveAuthBroker({ + cleanupLegacyCredentials: async () => { + cleanupCalls++; + if (cleanupCalls === 1) throw new Error("legacy cache is locked"); + }, + createCredential: () => ({ + async getToken() { + return accessToken(); + }, + }), + }); + + await assert.rejects(broker.getToken(), /legacy cache is locked/); + assert.equal(await broker.getToken(), "token"); + assert.equal(cleanupCalls, 2); +}); + +test("token acquisition preserves operational errors and retries the credential", async () => { + const outage = new Error("Azure Identity network request timed out"); + let createCredentialCalls = 0; + let tokenCalls = 0; + const broker = new InteractiveAuthBroker({ + createCredential: () => { + createCredentialCalls++; + return { + async getToken() { + tokenCalls++; + if (tokenCalls === 1) throw outage; + return accessToken(); + }, + }; + }, + }); + + await assert.rejects(broker.getToken(), (error) => error === outage); + assert.equal(await broker.getToken(), "token"); + assert.equal(createCredentialCalls, 1); + assert.equal(tokenCalls, 2); +}); + +test("incomplete tokens remain operational errors instead of prompting sign-in", async () => { + const broker = new InteractiveAuthBroker({ + createCredential: () => ({ + async getToken() { + return { token: "incomplete" }; + }, + }), + }); + + await assert.rejects( + broker.getToken(), + (error) => !(error instanceof ConnectorAuthenticationRequiredError) + && error.message === "Azure identity returned an incomplete ARM access token.", + ); +}); diff --git a/extensions/connector-namespaces/extension.mjs b/extensions/connector-namespaces/extension.mjs index fe6f502e..94025ee7 100644 --- a/extensions/connector-namespaces/extension.mjs +++ b/extensions/connector-namespaces/extension.mjs @@ -44,7 +44,7 @@ const session = await joinSession({ createCanvas({ id: "connector-namespaces", displayName: "MCP Connectors", - description: "Browse, connect, and open MCP connectors in the Azure Connector Namespace Sandbox.", + description: "Discover, connect, and manage hosted MCP servers from Azure Connector Namespace.", inputSchema: { type: "object", properties: { diff --git a/extensions/connector-namespaces/install.reauth.test.mjs b/extensions/connector-namespaces/install.reauth.test.mjs index 50da0470..b1f2cfa0 100644 --- a/extensions/connector-namespaces/install.reauth.test.mjs +++ b/extensions/connector-namespaces/install.reauth.test.mjs @@ -12,26 +12,18 @@ import { test, after } from "node:test"; import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; -import { delimiter, join } from "node:path"; +import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { tmpdir } from "node:os"; // Isolate COPILOT_HOME before importing install.mjs because its paths are bound at -// module-eval time. Put a fake Azure CLI on PATH so getToken() stays offline, and -// seed a profile config so the local entry reads as inCli. +// module-eval time. Seed the interactive broker with an in-memory token so ARM +// calls stay offline, and seed a profile config so the local entry reads as inCli. const TMP = mkdtempSync(join(tmpdir(), "cn-reauth-")); process.env.COPILOT_HOME = TMP; process.env.USERPROFILE = TMP; // homedir() on Windows process.env.HOME = TMP; // homedir() on posix -const binDir = join(TMP, "bin"); -mkdirSync(binDir, { recursive: true }); -const tokenJson = JSON.stringify({ accessToken: "fake-token", expires_on: Math.floor(Date.now() / 1000) + 3600 }); -writeFileSync(join(binDir, "az"), `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(tokenJson)});\n`); -chmodSync(join(binDir, "az"), 0o755); -writeFileSync(join(binDir, "az.cmd"), `@echo ${tokenJson}\r\n`); -process.env.PATH = `${binDir}${delimiter}${process.env.PATH || ""}`; - const legacyAuthCache = join(TMP, "extensions", "connector-namespaces", "artifacts", "auth-cache.json"); mkdirSync(join(TMP, "extensions", "connector-namespaces", "artifacts"), { recursive: true }); writeFileSync(legacyAuthCache, JSON.stringify({ accessToken: "legacy", refreshToken: "legacy" })); @@ -41,6 +33,14 @@ writeFileSync( JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }), ); +const { interactiveAuth } = await import("./auth.mjs"); +interactiveAuth.credential = { + async getToken() { + return { token: "fake-token", expiresOnTimestamp: Date.now() + 60 * 60 * 1000 }; + }, +}; +interactiveAuth.accessToken = { token: "fake-token", expiresOnTimestamp: Date.now() + 60 * 60 * 1000 }; + // Dynamic import AFTER the env is set. A static top-level import would be hoisted // and evaluate install.mjs (binding the paths to the real home) before the env // assignments run. diff --git a/extensions/connector-namespaces/package.json b/extensions/connector-namespaces/package.json index ac0c28b0..26f54bbc 100644 --- a/extensions/connector-namespaces/package.json +++ b/extensions/connector-namespaces/package.json @@ -1,9 +1,9 @@ { "name": "connector-namespaces", - "version": "1.1.0", + "version": "1.2.0", "type": "module", "main": "extension.mjs", - "description": "Browse, connect, and open MCP connectors from your Azure Connector Namespace in Sandbox.", + "description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.", "keywords": [ "azure", "connector-namespace", @@ -14,6 +14,7 @@ ], "license": "MIT", "dependencies": { + "@azure/identity": "4.13.1", "@github/copilot-sdk": "1.0.6" } } diff --git a/extensions/connector-namespaces/preview/README.md b/extensions/connector-namespaces/preview/README.md index 14b839b1..4413d5fb 100644 --- a/extensions/connector-namespaces/preview/README.md +++ b/extensions/connector-namespaces/preview/README.md @@ -4,7 +4,7 @@ A standalone way to **see** every canvas state without launching the Copilot app. It imports the real, pure renderer functions from `../renderer.mjs` and serves each state on a fixed loopback port, with every `/api/*` endpoint stubbed so you can force the states that keep regressing (the connecting spinner and the -"Restart your Copilot session…" banner). +"Restart the GitHub Copilot app…" banner). This exists because those two bugs have each shipped multiple times: diff --git a/extensions/connector-namespaces/renderer.mjs b/extensions/connector-namespaces/renderer.mjs index e4ddf609..b2a74c6a 100644 --- a/extensions/connector-namespaces/renderer.mjs +++ b/extensions/connector-namespaces/renderer.mjs @@ -78,6 +78,7 @@ export function baseStyles() { } } * { box-sizing: border-box; } +[hidden] { display: none !important; } body { font-family: "Segoe UI Variable", "Segoe UI", -apple-system, system-ui, sans-serif; margin: 0; @@ -303,14 +304,23 @@ button:focus-visible, a:focus-visible, [tabindex]:focus-visible { outline: 2px s // Setup / Namespace Picker // --------------------------------------------------------------------------- -export function renderSetupHtml(subscriptions, notice = "", capabilityToken = "") { +export function renderSetupHtml(subscriptions = [], notice = "", capabilityToken = "", { linkedNamespace = "" } = {}) { + const hasLinkedNamespace = typeof linkedNamespace === "string" && linkedNamespace.length > 0; + const pageTitle = hasLinkedNamespace ? "Sign in to MCP Connectors" : "Select Connector Namespace"; + const heading = hasLinkedNamespace ? "Sign in to see your connectors" : "Select a Connector Namespace"; + const subheading = hasLinkedNamespace + ? `Connector namespace ${esc(linkedNamespace)} is already linked.` + : "Choose which connector namespace to browse. This choice is saved for future sessions."; + const defaultSigninMessage = hasLinkedNamespace + ? "Sign in to Azure to view and manage its connectors." + : "Sign in to Azure to load your subscriptions and connector namespaces."; const subOptions = subscriptions.map((s) => `` ).join(""); return ` -Select Connector Namespace${baseStyles()} +${pageTitle}${baseStyles()}
-

${brandMark(30, "setup")}Select a Connector Namespace

-
Choose which connector namespace to browse. This choice is saved for future sessions.
+

${brandMark(30, "setup")}${heading}

+
${subheading}
${notice ? `
${esc(notice)}
` : ""} -
- + -
- - -
- -
-
Select a subscription to see available connector namespaces.
+
+
+ +
+
+ + +
+ +
+
${subscriptions.length ? "Select a subscription to see available connector namespaces." : "Loading subscriptions\u2026"}
+
`; } @@ -592,11 +796,6 @@ export function renderCatalogHtml(instanceId, catalog, { filter, category, sourc .restart-banner .rb-dismiss { flex:none; appearance:none; border:0; background:transparent; color:var(--fg-muted); font:inherit; font-size:.78rem; cursor:pointer; padding:.1rem .35rem; border-radius:4px; } .restart-banner .rb-dismiss:hover { color:var(--accent); background:var(--bg-hover); } .is-hidden { display:none !important; } -/* The [hidden] attribute must always win. A class rule like .restart-banner{display:flex} - has the same (0,1,0) specificity as the UA [hidden]{display:none} rule and, being an - author rule, overrides it -- so setting el.hidden=true does nothing and dismiss silently - breaks. This reset restores the attribute's authority for every element. */ -[hidden] { display:none !important; } /* ---- split "remove" control + its popover menu + delete-confirm dialog ---- */ /* main + caret read as one pill; the shared 1px border between them is the @@ -688,7 +887,7 @@ export function renderCatalogHtml(instanceId, catalog, { filter, category, sourc
${sectionsHtml} @@ -866,9 +1065,9 @@ if (input.value) applyFilters(); // for now because it writes a plaintext API key into a git-tracked .mcp.json. const installScope = "profile"; -// --- Restart-required banner (tools load at session start) --- +// --- Restart-required banner (tools load at app start) --- // Visibility is driven by the server's in-process pendingRestart flag via -// /api/state, not local storage — a real session restart spawns a fresh +// /api/state, not local storage — a full app restart spawns a fresh // extension process and clears it, so the banner can't go stale. const restartBanner = document.getElementById("restart-banner"); // Once the user dismisses the banner, a late/racing hydrateState (its @@ -1068,7 +1267,7 @@ function openSignInModal(displayName, consentUrl) { cancellable = false; icon.innerHTML = '
\u2713
'; title.textContent = "Connected"; - sub.textContent = displayName + " is configured. Restart your Copilot session to load its tools."; + sub.textContent = displayName + " is configured. Restart the GitHub Copilot app to load its tools."; meta.textContent = ""; }, close() { doClose(); }, @@ -1116,7 +1315,7 @@ async function onConnect(btn) { pendingConn = null; } - await showConnectionSuccess(modal, item, 'Connected "' + displayName + '". Restart your session to use its tools.'); + await showConnectionSuccess(modal, item, 'Connected "' + displayName + '". Restart the GitHub Copilot app to use its tools.'); } catch (err) { const recovery = await recoverConnectorFailure( err, @@ -1128,7 +1327,7 @@ async function onConnect(btn) { true ); if (recovery.complete) { - await showConnectionSuccess(modal, item, 'Connected "' + displayName + '". Restart your session to use its tools.'); + await showConnectionSuccess(modal, item, 'Connected "' + displayName + '". Restart the GitHub Copilot app to use its tools.'); return; } if (modal) modal.close(); @@ -1218,7 +1417,7 @@ async function onReauth(btn) { await showConnectionSuccess( modal, item, - (isConnect ? 'Connected "' : 'Re-authenticated "') + displayName + '". Restart your session to use its tools.' + (isConnect ? 'Connected "' : 'Re-authenticated "') + displayName + '". Restart the GitHub Copilot app to use its tools.' ); } catch (err) { const recovery = await recoverConnectorFailure( @@ -1234,7 +1433,7 @@ async function onReauth(btn) { await showConnectionSuccess( modal, item, - (isConnect ? 'Connected "' : 'Re-authenticated "') + displayName + '". Restart your session to use its tools.' + (isConnect ? 'Connected "' : 'Re-authenticated "') + displayName + '". Restart the GitHub Copilot app to use its tools.' ); return; } diff --git a/extensions/connector-namespaces/renderer.test.mjs b/extensions/connector-namespaces/renderer.test.mjs index d0c464e4..fbb57e52 100644 --- a/extensions/connector-namespaces/renderer.test.mjs +++ b/extensions/connector-namespaces/renderer.test.mjs @@ -7,7 +7,7 @@ // loaders without a visible fallback. Reduced motion now stops the // animation while forcing each loader into a visible static busy state; // nearby text continues to communicate progress. -// 2. The "Restart your Copilot session" banner ignoring Dismiss. The real +// 2. The "Restart the GitHub Copilot app" banner ignoring Dismiss. The real // root cause was CSS specificity: `.restart-banner{display:flex}` is an // author rule with the same (0,1,0) specificity as the UA // `[hidden]{display:none}` rule, so it overrode the hidden attribute and @@ -56,6 +56,54 @@ test("setup subscription label names its select", () => { assert.match(html, /