diff --git a/.claude/commands/gallery.md b/.claude/commands/gallery.md new file mode 100644 index 000000000..6afca7b27 --- /dev/null +++ b/.claude/commands/gallery.md @@ -0,0 +1,16 @@ +--- +description: Launch the HVE Cockpit agent gallery (all 65 agents) and show it live in the preview pane +argument-hint: "" +--- + +# Gallery + +Open the **HVE Cockpit gallery** surface (the scrollable contact sheet of every HVE Core agent) and show it live in the Claude Code preview pane. Do this without asking for confirmation. + +1. **Build if needed.** From `rpi-cockpit/`, run `npm run build` only when `dist/` is missing or older than `src/`; otherwise skip it. +2. **Launch the producer in the background:** `cd rpi-cockpit && node tools/agent-gallery.mjs` (pass `PORT=` if 4505 is taken). It renders every agent through the real cockpit client and prints `agent gallery: http://127.0.0.1:/?key=` while it keeps serving. Capture that full keyed URL from its output. +3. **Show it in the preview pane.** Start or reuse a preview server, then navigate the preview to the captured keyed URL (`preview_eval` setting `window.location.href`). The page loads the full cockpit with the `gallery` domain already open. +4. **Verify** with a screenshot (agent tiles grouped by category, the S/M/L size toggle bottom-left of the header, click-to-expand lightbox) and a console-error check. +5. **Report** the keyed URL so the user can open it in their own browser, and remind them the **S/M/L** toggle resizes the tiles and clicking a tile opens the full-size lightbox. + +Leave the producer running so the user can keep exploring; it serves until killed. diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..084238b35 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "rpi-cockpit", + "runtimeExecutable": "node", + "runtimeArgs": ["rpi-cockpit/dist/index.js", "live"], + "autoPort": true + } + ] +} diff --git a/.github/agents/hve-core/memory.agent.md b/.github/agents/hve-core/memory.agent.md index 47c9065c1..8ef7f7809 100644 --- a/.github/agents/hve-core/memory.agent.md +++ b/.github/agents/hve-core/memory.agent.md @@ -5,15 +5,15 @@ handoffs: - label: "🗑️ Clear" agent: RPI Agent prompt: "/clear" - send: true + send: false - label: "🚀 Continue with RPI" agent: RPI Agent prompt: "/rpi suggest" - send: true + send: false - label: "🚀 Continue with Backlog" agent: GitHub Backlog Manager prompt: "/github-suggest" - send: true + send: false --- # Memory Agent diff --git a/.github/agents/hve-core/prompt-builder.agent.md b/.github/agents/hve-core/prompt-builder.agent.md index aa91f4868..dcb8f5fc7 100644 --- a/.github/agents/hve-core/prompt-builder.agent.md +++ b/.github/agents/hve-core/prompt-builder.agent.md @@ -16,19 +16,19 @@ handoffs: - label: "🛠️ Refactor" agent: Prompt Builder prompt: /prompt-refactor all prompt files in this conversation - send: true + send: false - label: "🤔 Analyze" agent: Prompt Builder prompt: /prompt-analyze all prompt files in this conversation - send: true + send: false - label: "🔧 Apply Fixes" agent: Prompt Builder prompt: "/prompt-build make updates based on findings in this conversation" - send: true + send: false - label: "♻️ Cleanup Sandbox" agent: Prompt Builder prompt: "Clear the sandbox for this conversation" - send: true + send: false --- # Prompt Builder diff --git a/.github/agents/hve-core/rpi-agent.agent.md b/.github/agents/hve-core/rpi-agent.agent.md index e80599cbb..6cab2e196 100644 --- a/.github/agents/hve-core/rpi-agent.agent.md +++ b/.github/agents/hve-core/rpi-agent.agent.md @@ -10,27 +10,27 @@ handoffs: - label: "1️⃣" agent: RPI Agent prompt: "/rpi continue=1" - send: true + send: false - label: "2️⃣" agent: RPI Agent prompt: "/rpi continue=2" - send: true + send: false - label: "3️⃣" agent: RPI Agent prompt: "/rpi continue=3" - send: true + send: false - label: "▶️ All" agent: RPI Agent prompt: "/rpi continue=all" - send: true + send: false - label: "🔄 Suggest" agent: RPI Agent prompt: "/rpi suggest" - send: true + send: false - label: "💾 Save" agent: Memory prompt: /checkpoint - send: true + send: false --- # RPI Agent @@ -99,6 +99,17 @@ When a task requires subagents but neither `runSubagent` nor `task` tools are av Treat the phase guidance below as operating defaults rather than ceremony. Delegate only when it materially improves the outcome. +## Cockpit narration + +When the `rpi-cockpit` MCP tools are available, narrate this workflow to the RPI Cockpit following `rpi-cockpit/agents/cockpit-instructions.md`. Skip silently when the tools are not connected. Map the beats as follows: + +* Call `session_begin(task, host)` once when the user request starts. +* Call `phase_enter(phase)` when entering each phase: `research`, `plan`, `implement`, `review`, and `discover`. +* Wrap every `Researcher Subagent` and `Phase Implementor` dispatch with `subagent_start(name, role)` before and `subagent_stop(name, result)` after. +* Call `validate(check, status)` for each check run in the Phase 3 validation gate. +* Call `artifact_update(path, summary)` after writing or updating each `.copilot-tracking/` artifact. +* In Phase 5, surface the Suggested Next Work list through `present_options(prompt, options[])` so the user can choose the next item in the cockpit. Act only on an explicit user selection; if no selection is made, stop and yield to the user rather than auto-starting the next cycle. + ## Context Discipline After any subagent returns, this turn must be lean: diff --git a/.github/agents/hve-core/task-challenger.agent.md b/.github/agents/hve-core/task-challenger.agent.md index 83f50d4b1..b0afbfd40 100644 --- a/.github/agents/hve-core/task-challenger.agent.md +++ b/.github/agents/hve-core/task-challenger.agent.md @@ -7,15 +7,15 @@ handoffs: - label: "🔬 Research Questions" agent: Task Researcher prompt: "/task-research Find and read the most recent challenge tracking document in .copilot-tracking/challenges/ (most recent by date prefix) for the Q&A log and unresolved items: these define the research scope." - send: true + send: false - label: "📋 Revise Plan" agent: Task Planner prompt: "/task-plan Find and read the most recent challenge tracking document in .copilot-tracking/challenges/ (most recent by date prefix) for challenge findings and unresolved items before planning." - send: true + send: false - label: "⚡ Implement Changes" agent: Task Implementor prompt: "/task-implement Address the immediate changes identified through the challenge session. Find and read the most recent challenge tracking document in .copilot-tracking/challenges/ (most recent by date prefix) for findings." - send: true + send: false --- # Task Challenger diff --git a/.github/agents/hve-core/task-implementor.agent.md b/.github/agents/hve-core/task-implementor.agent.md index 0b340fef3..80b60a943 100644 --- a/.github/agents/hve-core/task-implementor.agent.md +++ b/.github/agents/hve-core/task-implementor.agent.md @@ -9,7 +9,7 @@ handoffs: - label: "✅ Review" agent: Task Reviewer prompt: /task-review - send: true + send: false --- # Task Implementor @@ -63,6 +63,15 @@ The Researcher Subagent returns deep research findings: subagent research docume Subagents can run in parallel when investigating independent topics or executing independent phases. +## Cockpit narration + +When the `rpi-cockpit` MCP tools are available, narrate implementation progress to the RPI Cockpit following `rpi-cockpit/agents/cockpit-instructions.md`. Skip silently when the tools are not connected. Map the beats as follows: + +* Call `phase_enter("implement")` when implementation starts. +* Wrap every `Phase Implementor` dispatch with `subagent_start(name, role)` before and `subagent_stop(name, result)` after. +* Call `validate(check, status)` for each lint, type, test, and build check. +* Call `artifact_update(path, summary)` after updating the changes log in `.copilot-tracking/changes/`. + ## Context Discipline After any subagent returns, this turn must be lean: diff --git a/.github/agents/hve-core/task-planner.agent.md b/.github/agents/hve-core/task-planner.agent.md index 1a395a4cd..972418d7a 100644 --- a/.github/agents/hve-core/task-planner.agent.md +++ b/.github/agents/hve-core/task-planner.agent.md @@ -9,7 +9,7 @@ handoffs: - label: "⚡ Implement" agent: Task Implementor prompt: /task-implement - send: true + send: false --- # Task Planner @@ -54,6 +54,15 @@ Run `Plan Validator` using `runSubagent` or `task`, providing these inputs: Subagents can run in parallel when investigating independent topics or validating independent concerns. +## Cockpit narration + +When the `rpi-cockpit` MCP tools are available, narrate planning progress to the RPI Cockpit following `rpi-cockpit/agents/cockpit-instructions.md`. Skip silently when the tools are not connected. Map the beats as follows: + +* Call `phase_enter("plan")` when planning starts. +* Wrap every `Researcher Subagent` and `Plan Validator` dispatch with `subagent_start(name, role)` before and `subagent_stop(name, result)` after. +* Call `artifact_update(path, summary)` after writing or updating each plan, details, or planning log file in `.copilot-tracking/`. +* Present Planning Decisions (PD) choices through `present_options(prompt, options[])` instead of asking in chat, then act on the returned id. + ## Context Discipline After any subagent returns, this turn must be lean: diff --git a/.github/agents/hve-core/task-researcher.agent.md b/.github/agents/hve-core/task-researcher.agent.md index ed258d401..eed166a5c 100644 --- a/.github/agents/hve-core/task-researcher.agent.md +++ b/.github/agents/hve-core/task-researcher.agent.md @@ -8,7 +8,7 @@ handoffs: - label: "📋 Create Plan" agent: Task Planner prompt: /task-plan - send: true + send: false - label: "🔬 Deeper Research" agent: Task Researcher prompt: /task-research continue deeper research based on potential next research items @@ -46,6 +46,14 @@ Run `Researcher Subagent` with `runSubagent` or `task`, and parallelize calls wh Subagents can run in parallel when investigating independent topics or sources. +## Cockpit narration + +When the `rpi-cockpit` MCP tools are available, narrate research progress to the RPI Cockpit following `rpi-cockpit/agents/cockpit-instructions.md`. Skip silently when the tools are not connected. Map the beats as follows: + +* Call `phase_enter("research")` when research starts. +* Wrap every `Researcher Subagent` dispatch with `subagent_start(name, role)` before and `subagent_stop(name, result)` after. +* Call `artifact_update(path, summary)` after writing or updating each research document in `.copilot-tracking/research/`. + ## Context Discipline After any subagent returns, this turn must be lean: diff --git a/.github/agents/hve-core/task-reviewer.agent.md b/.github/agents/hve-core/task-reviewer.agent.md index 29c0ea9fe..d4024805e 100644 --- a/.github/agents/hve-core/task-reviewer.agent.md +++ b/.github/agents/hve-core/task-reviewer.agent.md @@ -10,15 +10,15 @@ handoffs: - label: "🔬 Research More" agent: Task Researcher prompt: /task-research - send: true + send: false - label: "📋 Revise Plan" agent: Task Planner prompt: /task-plan - send: true + send: false - label: "⚡ Implement Immediately" agent: Task Implementor prompt: /task-implement Address the findings found in the review document - send: true + send: false --- # Implementation Reviewer @@ -33,6 +33,14 @@ Reviews completed implementation work from `.copilot-tracking/` artifacts. Valid * Match `applyTo` patterns from `.github/instructions/` files against changed file types to identify applicable conventions. * Subagents return structured, evidence-based responses with severity levels and can ask clarifying questions rather than guessing. +## Cockpit narration + +When the `rpi-cockpit` MCP tools are available, narrate review progress to the RPI Cockpit following `rpi-cockpit/agents/cockpit-instructions.md`. Skip silently when the tools are not connected. Map the beats as follows: + +* Call `phase_enter("review")` when review starts. +* Call `artifact_update(path, summary)` after writing or updating the review log in `.copilot-tracking/reviews/`. +* When review surfaces a choice for the user, present it through `present_options(prompt, options[])` instead of asking in chat, then act on the returned id. + ## Context Discipline After any subagent returns, this turn must be lean: diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 11928e992..012b0db1f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -297,3 +297,61 @@ The `copilot-setup-steps.yml` and `.devcontainer/scripts/on-create.sh` share mos 🤖 Crafted with precision by ✨Copilot following brilliant human instruction, then carefully refined by our team of discerning human reviewers. + + +## Cockpit instrumentation + +When the `rpi-cockpit` MCP tools are available, narrate your work to the cockpit by calling them. Pick the surface that matches what you are doing: the cockpit renders one view per kind of work, so a reviewer drives the findings panel, a backlog manager drives the kanban, an orchestrator drives the team board, and so on. These calls are informational and non-blocking unless noted. When the tools are not available, do nothing extra (the user's steering is still readable from disk, see the end). + +### Always, in any workflow + +* At the start of a coding session or build, call `session_begin(task, host)`. +* Call `set_context(instructions, skills, collection)` to show which coding standards, skills, and collection are active for this work. Pass everything currently active; omitting a field clears that group. +* At each major step, and before any decision, call `check_directives()`. It returns immediately with any notes or picks the user queued in the cockpit (or "no pending directives"). You MUST read and act on them. +* To let the user choose what to do, call `present_workflows()` (a native choice card in the chat), or `open_navigator()` to pop the in-cockpit workflow picker (for example on a `/Nav` request). +* To show the user's app under development beside the cockpit, call `set_app_frame(url)` with a loopback http(s) URL (localhost, 127.0.0.1, or [::1]); non-loopback URLs are rejected. Pass null to clear it. + +### Asking the user (any workflow, these BLOCK) + +* `present_options(prompt, options[])` for a bounded choice: it blocks until the user picks (in the cockpit or the native card) and returns the chosen `id`. Act on the returned id. +* `ask_question(prompt)` for a free-text answer: it blocks until the user answers and returns the text. + +### RPI build loop (research, plan, implement, review, discover) + +* `phase_enter(phase)` on entering each phase (research|plan|implement|review|discover). +* `subagent_start(name, role)` before a subagent and `subagent_stop(name, result)` after. +* `artifact_update(path, summary)` after writing a tracking file. +* `validate(check, status)` on each validation check (status ok|running|fail|pending). +* `offer_approaches(label, options[])` to populate the cockpit's Steer select with the real choices for the next phase (non-blocking). +* `show_screen(html, title?)` and `clear_screen()` for arbitrary static HTML (a mockup, a diff, rendered markdown, a diagram) in a sandboxed pane. + +### Reviews and audits (code review, PR review, security, accessibility, RAI) + +* `review_start(target)` when you begin a review (target is the branch, PR, or scope). +* `add_finding(severity, title, file?, line?, detail?)` per finding (severity critical|high|medium|low|info). The cockpit groups findings by severity with file links. + +### Guided document builders (PRD, BRD, ADR, security, RAI, accessibility plans) + +* `interview_start(docType)` when you begin the guided interview. +* Ask each question with `ask_question(prompt)` (it blocks for the answer); render the growing draft with `show_screen(html, "…")`. + +### Backlog orchestration (GitHub, ADO, Jira: discover, triage, sprint, execute) + +* `backlog_start(target, columns[])` to open the board (target is the sprint, repo, or project; columns are the ordered state names). +* `add_item(id, title, column, kind?, tier?)` to add or update a work item, `move_item(id, column)` as it progresses, and `set_backlog_action(text)` to show the action you are taking (null clears it). + +### Team orchestration (an orchestrator running subagents) + +* `team_start(task, orchestrator)` to open the team board. +* `add_agent(id, name, role?, status)` (status queued|running|blocked|done|failed), `update_agent(id, status?, action?)` as each subagent progresses, and `remove_agent(id)` when one leaves. +* The user's pause, swap, and spawn interventions arrive as directives: call `check_directives()` and act on them. The cockpit expresses intent only and never controls agents directly. + +### Codebase navigation (research and large edits) + +* `codemap_set(nodes[])` with the slice of files relevant to the task (each node has id, path, kind file|dir, and an optional group). This opens the spatial codebase map. +* `codemap_focus(id)` as you move to a file, and `codemap_touch(id, kind)` to mark a node read or edit (your trail through the code). + +### When the tools are not available + +Do not call them. The user's steering is still readable from disk: notes and approach picks are appended to `/directives.jsonl` and answered decisions to `/decisions.jsonl`, one JSON object per line. The `` is printed on startup (`rpi-cockpit: state dir `). + diff --git a/.gitignore b/.gitignore index 89ea5efcc..041c8c28f 100644 --- a/.gitignore +++ b/.gitignore @@ -421,6 +421,7 @@ FodyWeavers.xsd !.vscode/launch.json !.vscode/extensions.json !.vscode/*.code-snippets +!.vscode/mcp.json !.vscode/mcp.json.sample !.vscode/README.md @@ -488,6 +489,9 @@ dependency-pinning-artifacts/ .mcp/*.local.json .mcp/.env +# Codex MCP config holds a machine-specific absolute path (rpi-cockpit init) - never commit +/.codex/ + # Docusaurus build artifacts docs/docusaurus/build/ docs/docusaurus/.docusaurus/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..346bc3fd5 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "rpi-cockpit": { + "type": "stdio", + "command": "node", + "args": [ + "${CLAUDE_PROJECT_DIR}/rpi-cockpit/dist/index.js" + ], + "env": { + "RPI_COCKPIT_PORT": "4399" + } + } + } +} diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 000000000..d231d2938 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,12 @@ +{ + "servers": { + "rpi-cockpit": { + "type": "stdio", + "command": "node", + "args": [ + "${workspaceFolder}/rpi-cockpit/dist/index.js" + ], + "cwd": "${workspaceFolder}" + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..035854c97 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ + +# Cockpit instrumentation + +When the `rpi-cockpit` MCP tools are available, narrate your work to the cockpit by calling them. Pick the surface that matches what you are doing: the cockpit renders one view per kind of work, so a reviewer drives the findings panel, a backlog manager drives the kanban, an orchestrator drives the team board, and so on. These calls are informational and non-blocking unless noted. When the tools are not available, do nothing extra (the user's steering is still readable from disk, see the end). + +## Always, in any workflow + +* At the start of a coding session or build, call `session_begin(task, host)`. +* Call `set_context(instructions, skills, collection)` to show which coding standards, skills, and collection are active for this work. Pass everything currently active; omitting a field clears that group. +* At each major step, and before any decision, call `check_directives()`. It returns immediately with any notes or picks the user queued in the cockpit (or "no pending directives"). You MUST read and act on them. +* To let the user choose what to do, call `present_workflows()` (a native choice card in the chat), or `open_navigator()` to pop the in-cockpit workflow picker (for example on a `/Nav` request). +* To show the user's app under development beside the cockpit, call `set_app_frame(url)` with a loopback http(s) URL (localhost, 127.0.0.1, or [::1]); non-loopback URLs are rejected. Pass null to clear it. + +## Asking the user (any workflow, these BLOCK) + +* `present_options(prompt, options[])` for a bounded choice: it blocks until the user picks (in the cockpit or the native card) and returns the chosen `id`. Act on the returned id. +* `ask_question(prompt)` for a free-text answer: it blocks until the user answers and returns the text. + +## RPI build loop (research, plan, implement, review, discover) + +* `phase_enter(phase)` on entering each phase (research|plan|implement|review|discover). +* `subagent_start(name, role)` before a subagent and `subagent_stop(name, result)` after. +* `artifact_update(path, summary)` after writing a tracking file. +* `validate(check, status)` on each validation check (status ok|running|fail|pending). +* `offer_approaches(label, options[])` to populate the cockpit's Steer select with the real choices for the next phase (non-blocking). +* `show_screen(html, title?)` and `clear_screen()` for arbitrary static HTML (a mockup, a diff, rendered markdown, a diagram) in a sandboxed pane. + +## Reviews and audits (code review, PR review, security, accessibility, RAI) + +* `review_start(target)` when you begin a review (target is the branch, PR, or scope). +* `add_finding(severity, title, file?, line?, detail?)` per finding (severity critical|high|medium|low|info). The cockpit groups findings by severity with file links. + +## Guided document builders (PRD, BRD, ADR, security, RAI, accessibility plans) + +* `interview_start(docType)` when you begin the guided interview. +* Ask each question with `ask_question(prompt)` (it blocks for the answer); render the growing draft with `show_screen(html, "…")`. + +## Backlog orchestration (GitHub, ADO, Jira: discover, triage, sprint, execute) + +* `backlog_start(target, columns[])` to open the board (target is the sprint, repo, or project; columns are the ordered state names). +* `add_item(id, title, column, kind?, tier?)` to add or update a work item, `move_item(id, column)` as it progresses, and `set_backlog_action(text)` to show the action you are taking (null clears it). + +## Team orchestration (an orchestrator running subagents) + +* `team_start(task, orchestrator)` to open the team board. +* `add_agent(id, name, role?, status)` (status queued|running|blocked|done|failed), `update_agent(id, status?, action?)` as each subagent progresses, and `remove_agent(id)` when one leaves. +* The user's pause, swap, and spawn interventions arrive as directives: call `check_directives()` and act on them. The cockpit expresses intent only and never controls agents directly. + +## Codebase navigation (research and large edits) + +* `codemap_set(nodes[])` with the slice of files relevant to the task (each node has id, path, kind file|dir, and an optional group). This opens the spatial codebase map. +* `codemap_focus(id)` as you move to a file, and `codemap_touch(id, kind)` to mark a node read or edit (your trail through the code). + +## When the tools are not available + +Do not call them. The user's steering is still readable from disk: notes and approach picks are appended to `/directives.jsonl` and answered decisions to `/decisions.jsonl`, one JSON object per line. The `` is printed on startup (`rpi-cockpit: state dir `). + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..b25301406 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,63 @@ + +# Cockpit instrumentation + +When the `rpi-cockpit` MCP tools are available, narrate your work to the cockpit by calling them. Pick the surface that matches what you are doing: the cockpit renders one view per kind of work, so a reviewer drives the findings panel, a backlog manager drives the kanban, an orchestrator drives the team board, and so on. These calls are informational and non-blocking unless noted. When the tools are not available, do nothing extra (the user's steering is still readable from disk, see the end). + +## Always, in any workflow + +* At the start of a coding session or build, call `session_begin(task, host)`. +* Call `set_context(instructions, skills, collection)` to show which coding standards, skills, and collection are active for this work. Pass everything currently active; omitting a field clears that group. +* At each major step, and before any decision, call `check_directives()`. It returns immediately with any notes or picks the user queued in the cockpit (or "no pending directives"). You MUST read and act on them. +* To let the user choose what to do, call `present_workflows()` (a native choice card in the chat), or `open_navigator()` to pop the in-cockpit workflow picker (for example on a `/Nav` request). +* To show the user's app under development beside the cockpit, call `set_app_frame(url)` with a loopback http(s) URL (localhost, 127.0.0.1, or [::1]); non-loopback URLs are rejected. Pass null to clear it. + +## Asking the user (any workflow, these BLOCK) + +* `present_options(prompt, options[])` for a bounded choice: it blocks until the user picks (in the cockpit or the native card) and returns the chosen `id`. Act on the returned id. +* `ask_question(prompt)` for a free-text answer: it blocks until the user answers and returns the text. + +## RPI build loop (research, plan, implement, review, discover) + +* `phase_enter(phase)` on entering each phase (research|plan|implement|review|discover). +* `subagent_start(name, role)` before a subagent and `subagent_stop(name, result)` after. +* `artifact_update(path, summary)` after writing a tracking file. +* `validate(check, status)` on each validation check (status ok|running|fail|pending). +* `offer_approaches(label, options[])` to populate the cockpit's Steer select with the real choices for the next phase (non-blocking). +* `show_screen(html, title?)` and `clear_screen()` for arbitrary static HTML (a mockup, a diff, rendered markdown, a diagram) in a sandboxed pane. + +## Reviews and audits (code review, PR review, security, accessibility, RAI) + +* `review_start(target)` when you begin a review (target is the branch, PR, or scope). +* `add_finding(severity, title, file?, line?, detail?)` per finding (severity critical|high|medium|low|info). The cockpit groups findings by severity with file links. + +## Guided document builders (PRD, BRD, ADR, security, RAI, accessibility plans) + +* `interview_start(docType)` when you begin the guided interview. +* Ask each question with `ask_question(prompt)` (it blocks for the answer); render the growing draft with `show_screen(html, "…")`. + +## Backlog orchestration (GitHub, ADO, Jira: discover, triage, sprint, execute) + +* `backlog_start(target, columns[])` to open the board (target is the sprint, repo, or project; columns are the ordered state names). +* `add_item(id, title, column, kind?, tier?)` to add or update a work item, `move_item(id, column)` as it progresses, and `set_backlog_action(text)` to show the action you are taking (null clears it). + +## Gallery (show several things at once) + +* `gallery_open(title, items, size?)`, `gallery_add(item)`, `gallery_clear()` to open a scrollable grid, add/update a tile, and clear the board. Each item is one of a live `url` (loopback http(s) or external https) OR an inline `html` snapshot, plus a `label`, optional `group`, and optional `caption`. +* Use it to compare running apps or sites side by side, or rendered states. Clicking a tile expands it; external sites that block framing show blank with an open-in-tab link. +* On a `/gallery` request from the user, open this view: call `gallery_open` with whatever they name, or the full HVE Core agent showcase (`rpi-cockpit/tools/agent-gallery.mjs`) when they do not specify. + +## Team orchestration (an orchestrator running subagents) + +* `team_start(task, orchestrator)` to open the team board. +* `add_agent(id, name, role?, status)` (status queued|running|blocked|done|failed), `update_agent(id, status?, action?)` as each subagent progresses, and `remove_agent(id)` when one leaves. +* The user's pause, swap, and spawn interventions arrive as directives: call `check_directives()` and act on them. The cockpit expresses intent only and never controls agents directly. + +## Codebase navigation (research and large edits) + +* `codemap_set(nodes[])` with the slice of files relevant to the task (each node has id, path, kind file|dir, and an optional group). This opens the spatial codebase map. +* `codemap_focus(id)` as you move to a file, and `codemap_touch(id, kind)` to mark a node read or edit (your trail through the code). + +## When the tools are not available + +Do not call them. The user's steering is still readable from disk: notes and approach picks are appended to `/directives.jsonl` and answered decisions to `/decisions.jsonl`, one JSON object per line. The `` is printed on startup (`rpi-cockpit: state dir `). + diff --git a/mockups/rpi-cockpit-fluent.html b/mockups/rpi-cockpit-fluent.html new file mode 100644 index 000000000..91b456eed --- /dev/null +++ b/mockups/rpi-cockpit-fluent.html @@ -0,0 +1,231 @@ + + + + + +RPI Cockpit — theme compare (Fluent · VS Code · Mica) + + + +
+ +
+
RPI Cockpit
+
Refactor auth module · RPI session
+
+
+ + + +
+ live + via MCP · Claude Code + +
+ +
+ + +
+

Phase 3 · Implement

● running
+
Executing the plan from .copilot-tracking/plans/. Subagents and validation run here; the loop won't advance to Review until checks pass.
+ +
Live subagents
+
+
PI
Phase Implementor
applying step 2.2 · editing auth/middleware.ts
active
+
RS
Researcher Subagent
idle · last ran for plan gap "session store"
idle
+
+ +
Validation gate
+
+ lint + types + tests + ○ build +
+ +
Action needed
+
+
The agent is asking you to choose an approachpresent_options · awaiting your pick
+
+
+

A · Minimal patch

Guard the existing handler in place. Smallest diff, ships today.

Trade-off: leaves the duplication.
+
RECOMMENDED

B · Token middleware

Extract a reusable middleware layer. Clean seam, moderate change.

Trade-off: touches 6 files.
+

C · Full rewrite

Replace the auth module with a policy engine. Most durable.

Trade-off: new dependency, higher risk.
+
+
agent is paused on your decision
+
+
+
+ + +
+
+ + + diff --git a/rpi-cockpit/.gitignore b/rpi-cockpit/.gitignore new file mode 100644 index 000000000..f31da2674 --- /dev/null +++ b/rpi-cockpit/.gitignore @@ -0,0 +1,5 @@ +# Build output (tsc rootDir src -> outDir dist) +dist/ + +# Dependencies +node_modules/ diff --git a/rpi-cockpit/.mcp.json.example b/rpi-cockpit/.mcp.json.example new file mode 100644 index 000000000..5befa877a --- /dev/null +++ b/rpi-cockpit/.mcp.json.example @@ -0,0 +1,10 @@ +{ + "mcpServers": { + "rpi-cockpit": { + "type": "stdio", + "command": "node", + "args": ["${CLAUDE_PROJECT_DIR}/rpi-cockpit/dist/index.js"], + "env": { "RPI_COCKPIT_PORT": "4399" } + } + } +} diff --git a/rpi-cockpit/README.md b/rpi-cockpit/README.md new file mode 100644 index 000000000..607bb2071 --- /dev/null +++ b/rpi-cockpit/README.md @@ -0,0 +1,101 @@ +# RPI Cockpit + +A real-time browser dashboard that lets you monitor and steer an RPI (Research-Plan-Implement) agent loop running inside Claude Code. + +## Install and build + +```bash +cd rpi-cockpit +npm install +npm run build +``` + +## Running the cockpit in Claude Code / Codex / VS Code + +The `init` command wires the cockpit into each host's MCP config and inlines the +narration contract into that host's agent-instructions file. Configs and narration +are written idempotently — re-running `init` updates in place rather than duplicating. + +### One-time setup + +```bash +# 1. Install + build (the `prepare` script runs `tsc` and produces dist/index.js): +cd rpi-cockpit && npm install + +# 2. Wire one host (or all of them): +npx --no-install rpi-cockpit init --host +# or, equivalently, without relying on the bin link: +node rpi-cockpit/dist/index.js init --host +``` + +Run `init` from the **repository root** so the project-scoped configs land in the +right place. The web UI always serves on `http://127.0.0.1:4399` (loopback only); +override the port with `RPI_COCKPIT_PORT`. + +### Claude Code + +`init --host claude` writes: + +- `.mcp.json` — registers `mcpServers.rpi-cockpit` (`type: "stdio"`, launched via + `node ${CLAUDE_PROJECT_DIR}/rpi-cockpit/dist/index.js`). This file is committed, + so the cockpit is available to everyone who opens the repo in Claude Code. +- `CLAUDE.md` — the narration contract, inside a + `` block. + +Open the project in Claude Code; it starts the server automatically. Confirm the +seven tools (`session_begin`, `phase_enter`, `subagent_start`, `subagent_stop`, +`artifact_update`, `validate`, `present_options`) appear, then open +. + +### Codex + +`init --host codex` writes: + +- `.codex/config.toml` — the `[mcp_servers.rpi-cockpit]` table. **This config is + machine-specific** (it uses absolute paths) and is git-ignored, so it is *not* + committed; each developer generates their own. Pass `--codex-global` to write it + to `~/.codex/config.toml` instead of the project's `.codex/config.toml`. +- `AGENTS.md` — the narration contract block (committed). + +Because Codex configs need absolute paths, the generated table looks like this +(replace `` with your checkout's absolute path): + +```toml +[mcp_servers.rpi-cockpit] +command = "node" +args = ["/rpi-cockpit/dist/index.js"] +cwd = "" +startup_timeout_sec = 20 +``` + +Start Codex, confirm the seven `rpi-cockpit` tools are listed, then open +. + +### VS Code (Copilot) + +`init --host vscode` writes: + +- `.vscode/mcp.json` — registers `servers.rpi-cockpit` (`type: "stdio"`, + `${workspaceFolder}/rpi-cockpit/dist/index.js`, `cwd: "${workspaceFolder}"`). + Committed so the workspace picks it up automatically. +- `.github/copilot-instructions.md` — the narration contract block is appended + (existing content is preserved; committed). + +Reload the VS Code window so Copilot picks up the MCP server, confirm the seven +`rpi-cockpit` tools are available, then open . + +## Open the dashboard + +With the server running, open your browser to: + +``` +http://127.0.0.1:4399 +``` + +The dashboard updates in real time over WebSocket as the agent calls the cockpit beats. + +## Agent instrumentation + +See [`agents/cockpit-instructions.md`](agents/cockpit-instructions.md) for the snippet that tells an RPI agent when to call each beat (`session_begin`, `phase_enter`, `subagent_start`, `subagent_stop`, `artifact_update`, `validate`, `present_options`). + +Add the contents of that file to your agent's system prompt or CLAUDE.md so it narrates its work through the cockpit. diff --git a/rpi-cockpit/agents/cockpit-instructions.md b/rpi-cockpit/agents/cockpit-instructions.md new file mode 100644 index 000000000..90f17f6d5 --- /dev/null +++ b/rpi-cockpit/agents/cockpit-instructions.md @@ -0,0 +1,99 @@ +# Cockpit instrumentation + +When the `rpi-cockpit` MCP tools are available, narrate your work to the cockpit by calling them. Pick the surface that matches what you are doing: the cockpit renders one view per kind of work, so a reviewer drives the findings panel, a backlog manager drives the kanban, an orchestrator drives the team board, and so on. These calls are informational and non-blocking unless noted. When the tools are not available, do nothing extra (the user's steering is still readable from disk, see the end). + +## Always, in any workflow + +* At the start of a coding session or build, call `session_begin(task, host)`. +* Call `set_context(instructions, skills, collection)` to show which coding standards, skills, and collection are active for this work. Pass everything currently active; omitting a field clears that group. +* At each major step, and before any decision, call `check_directives()`. It returns immediately with any notes or picks the user queued in the cockpit (or "no pending directives"). You MUST read and act on them. +* To let the user choose what to do, call `present_workflows()` (a native choice card in the chat), or `open_navigator()` to pop the in-cockpit workflow picker (for example on a `/Nav` request). +* To show the user's app under development beside the cockpit, call `set_app_frame(url)` with a loopback http(s) URL (localhost, 127.0.0.1, or [::1]); non-loopback URLs are rejected. Pass null to clear it. + +## Asking the user (any workflow, these BLOCK) + +* `present_options(prompt, options[])` for a bounded choice: it blocks until the user picks (in the cockpit or the native card) and returns the chosen `id`. Act on the returned id. +* `ask_question(prompt)` for a free-text answer: it blocks until the user answers and returns the text. +* The cockpit shows your questions and decisions as a navigable flow. If `check_directives()` returns a note like `revise decision "…" (id X)`, the user wants to change an earlier answer: re-ask that decision by calling the same tool with `id: "X"`, then reconsider the questions that follow, since the new answer may change them. + +## RPI build loop (research, plan, implement, review, discover) + +* `phase_enter(phase)` on entering each phase (research|plan|implement|review|discover). +* `subagent_start(name, role)` before a subagent and `subagent_stop(name, result)` after. +* `artifact_update(path, summary)` after writing a tracking file. +* `validate(check, status)` on each validation check (status ok|running|fail|pending). +* `offer_approaches(label, options[])` to populate the cockpit's Steer select with the real choices for the next phase (non-blocking). +* `show_screen(html, title?)` and `clear_screen()` for arbitrary static HTML (a mockup, a diff, rendered markdown, a diagram) in a sandboxed pane. + +## Reviews and audits (code review, PR review, security, accessibility, RAI) + +* `review_start(target)` when you begin a review (target is the branch, PR, or scope). +* `add_finding(severity, title, file?, line?, detail?)` per finding (severity critical|high|medium|low|info). The cockpit groups findings by severity with file links. +* If your review runs a pipeline of subagents (profile, assess, verify, report), call `subagent_start(name, role)` / `subagent_stop(name, result)` for each: the findings panel shows them as a live "reviewers" strip above the findings, so the user sees progress during a long scan instead of an empty panel. +* If your review is narrative rather than a list of graded findings (for example a PR walkthrough of design forks and architectural shape), render it with `show_screen(html, title)` as rendered markdown; reserve `review_start` + `add_finding` and the findings panel for severity-graded findings. +* If `check_directives()` returns a note like `open : in the editor`, the user clicked a finding's open control: open that file (at the line, if given). + +## Guided document builders (PRD, BRD, ADR, security, RAI, accessibility plans) + +* `interview_start(docType)` when you begin the guided interview. +* Ask each question with `ask_question(prompt)` (it blocks for the answer); render the growing draft with `show_screen(html, "…")`. +* If your interview runs a multi-step program (a phase-gated planner like ADR Frame/Decide/Govern or a six-phase assessment, or a coach running a curriculum or + method sequence), call `set_steps(steps, current, label?, progress?)` when you begin and again as you advance (a higher `current`): the interview view shows a + progress stepper above the conversation so the user sees the whole roadmap and the current step. Re-declare `steps` if an adaptive program's path changes. + Pass progress as { done, total } to show sub-progress on the active step (for example a comprehension check 2 of 3). + +## Backlog orchestration (GitHub, ADO, Jira: discover, triage, sprint, execute) + +* `backlog_start(target, columns[])` to open the board (target is the sprint, repo, or project; columns are the ordered state names). +* `add_item(id, title, column, kind?, tier?, parent?)` to add or update a work item, `move_item(id, column)` as it progresses, and `set_backlog_action(text)` to show the action you are taking (null clears it). +* Pass `parent` (a parent item's id) to nest the item: the board indents a child under its parent when both are in the same column and shows "↳ under {parent}" when they are not. A PRD-to-WIT planner proposing an Epic→Feature→Story→Task tree should pass `parent` and add the items to one planning column so the whole tree nests. + +## Data science (dataset profiling, notebooks, dashboards) + +* `dataset_profile(name, rows?, columns?, source?)` opens the data-profile table view; then call `add_column(name, dtype, nullPct?, distinct?, stat?, quality?)` once per field, with `quality` one of ok/warn/risk for a data-quality flag. Use this for a data dictionary or profile (the Data Spec agent). +* For a generated notebook or data spec document, render the preview with `show_screen(html, title)`. +* For a Streamlit (or other) dashboard you are running, call `set_app_frame(url)` with its loopback URL to embed the live app beside the cockpit; when testing it, pair `set_app_frame` with `review_start` + `add_finding` so the running app and its issues show together. +* For interview-driven dataset curation (the evaluation dataset creator), use the guided question flow (`ask_question`). + +## Gallery (show several things at once) + +* `gallery_open(title, items, size?)` opens a scrollable grid of scaled live thumbnails. Each item is one of a live `url` (a website or a loopback dev server, framed live) OR an inline `html` snapshot, plus a `label`, optional `group` (a section header), and optional `caption`. `url` must be a loopback http(s) URL or an external https URL. `size` is s/m/l (default m). +* `gallery_add(item)` adds or updates one tile by `id`; `gallery_clear()` empties the board. +* Use it to compare several running apps or sites side by side (`url` items), or several rendered states (`html` items). Clicking a tile expands it; external sites that block framing show blank, so an open-in-tab link is always offered. +* On a `/gallery` request from the user, open this view: call `gallery_open` with whatever they name (sites, running apps, rendered states), or the full HVE Core agent showcase (`rpi-cockpit/tools/agent-gallery.mjs`) when they do not specify. + +## Prompt engineering (the prompt workbench) + +* `promptlab_start(name, prompt?, round?)` opens the prompt workbench (a behavior test bench) and switches the cockpit to it. The Prompt Builder calls this when it begins hardening a prompt; pass the prompt's current text as `prompt` and the iteration round (default 1). Re-call with `round + 1` for a fresh pass. +* `add_case(id, scenario, output?, verdict?, note?)` adds or updates one test case. The Prompt Tester calls `add_case(id, scenario)` as it picks each scenario, then updates the same id with the literal output it produced, a verdict (pending/running/pass/warn/fail), and an optional note once it runs and the Prompt Evaluator judges. +* When the Prompt Evaluator's output is prompt-wide rather than per-case, it may still narrate severity findings via `review_start` + `add_finding`. + +## Memory (the memory store) + +* `memory_open(title?)` opens the Memory view and switches the cockpit to it; optionally name the collection. The Memory agent calls this when it activates. +* `add_memory(id, content, category, tag?, title?)` adds or updates one memory entry: a recalled or written fact, grouped by `category` (a memory type like user/feedback/project/reference, or a source). Tag it `recalled` (loaded into context), `added` (written this session), or `updated`; give an optional short `title`. +* `add_handoff(id, from, summary, action?)` records another agent handing state to Memory: `from` is the agent's name, `summary` is what was handed, `action` is stored/merged/recalled. +* The context badges (`set_context`) remain the active-standards strip and are orthogonal to this store. + +## Agentic workflows (the flow canvas) + +* `flow_open(title?)` opens the flow canvas (the gh-aw agentic-workflow pipeline as a node graph) and switches the cockpit to it. The GitHub Agentic Workflows agent calls this when it begins working a pipeline. +* `add_flow_node(id, kind, label, scope?, sub?, status?)` adds or updates one node. Use `kind: workflow` (scope orchestration, the default) for each workflow in the pipeline, and `kind` trigger/guard/agent/output/mcp with `scope` set to a workflow's node id for that workflow's anatomy. `status` (idle/running/passed/failed/skipped/stale) drives the live-run look; `sub` is a short subtitle. +* `add_flow_edge(id, from, to, scope?, label?, kind?, status?)` wires two nodes. Orchestration handoffs use `kind` label/event/output with the handoff `label` (for example a label name like `agent-ready`); anatomy steps use `kind: step`. Set `status: active` on the edge currently firing. +* Narrate a live run by re-calling `add_flow_node` / `add_flow_edge` with a new `status` as the pipeline fires, and `flow_focus(workflow)` to drill the pane into a workflow (or `flow_focus()` to return to the pipeline), for example to show where a run failed. +* This surface narrates and the user steers (via `check_directives`); it does not author or run workflows. The agent edits the `.md` and runs `gh aw compile` / `logs` / `audit` itself. + +## Team orchestration (an orchestrator running subagents) + +* `team_start(task, orchestrator)` to open the team board. +* `add_agent(id, name, role?, status)` (status queued|running|blocked|done|failed), `update_agent(id, status?, action?)` as each subagent progresses, and `remove_agent(id)` when one leaves. +* The user's pause, swap, and spawn interventions arrive as directives: call `check_directives()` and act on them. The cockpit expresses intent only and never controls agents directly. + +## Codebase navigation (research and large edits) + +* `codemap_set(nodes[])` with the slice of files relevant to the task (each node has id, path, kind file|dir, and an optional group). This opens the spatial codebase map. +* `codemap_focus(id)` as you move to a file, and `codemap_touch(id, kind)` to mark a node read or edit (your trail through the code). + +## When the tools are not available + +Do not call them. The user's steering is still readable from disk: notes and approach picks are appended to `/directives.jsonl` and answered decisions to `/decisions.jsonl`, one JSON object per line. The `` is printed on startup (`rpi-cockpit: state dir `). diff --git a/rpi-cockpit/demo.mjs b/rpi-cockpit/demo.mjs new file mode 100644 index 000000000..036cd2078 --- /dev/null +++ b/rpi-cockpit/demo.mjs @@ -0,0 +1,88 @@ +// Throwaway LIVE demo: drives a timed RPI session into the cockpit so the UI +// animates — phases advance, subagents flip, the gate lights up, the Steer panel +// offers an agent-declared approach menu, and two decisions block until you click. +// Not part of the package. +import { Bridge } from "./dist/bridge.js"; +import { startServer } from "./dist/server.js"; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const b = new Bridge(); +const srv = await startServer(b, Number(process.env.RPI_COCKPIT_PORT ?? 4399)); +// The cockpit now requires the per-session ?key=… token; print the keyed url. +process.stderr.write(`RPI Cockpit LIVE demo at ${srv.url} — open that exact URL; it starts in ~3s\n`); + +// Simulate the agent pulling steering directives at its checkpoints: every 2s drain +// anything you queued from the cockpit's Steer panel and echo it here, so a note or +// approach pick visibly round-trips (queued -> consumed) instead of just sitting. +setInterval(() => { + for (const d of b.drainDirectives()) { + process.stderr.write(`[demo] steer directive received: ${d.kind === "note" ? `note "${d.text}"` : `approach ${d.label}`}\n`); + } +}, 2000); + +async function run() { + await sleep(3000); // lead-in so you can refresh and watch from the start + + b.emitBeat({ type: "session.begin", task: "Refactor auth module", host: "demo" }); + await sleep(1200); + + b.emitBeat({ type: "phase.enter", phase: "research" }); + b.emitBeat({ type: "subagent.start", name: "Researcher Subagent", role: "scanning auth + session store" }); + await sleep(2600); + b.emitBeat({ type: "artifact.update", path: ".copilot-tracking/research/auth-research.md", summary: "findings + 2 approaches" }); + b.emitBeat({ type: "subagent.stop", name: "Researcher Subagent", result: "done" }); + await sleep(1200); + + b.emitBeat({ type: "phase.enter", phase: "plan" }); + // Offer a structured choice for the NEXT phase while still in plan, so the cockpit's + // Steer select shows these agent-declared options. Entering implement clears the menu + // (a menu offered for one phase doesn't linger) and it falls back to the fixed presets. + b.offerApproaches("Implementor for the implement phase", [ + { id: "default", title: "Phase Implementor (default)" }, + { id: "tdd", title: "TDD-first implementor" }, + { id: "surgical", title: "Surgical minimal-diff" }, + ]); + await sleep(2200); + b.emitBeat({ type: "artifact.update", path: ".copilot-tracking/plans/auth-plan.md", summary: "3 phases" }); + await sleep(1600); + + b.emitBeat({ type: "phase.enter", phase: "implement" }); + b.emitBeat({ type: "subagent.start", name: "Phase Implementor", role: "applying phase 2 · auth/middleware.ts" }); + await sleep(1600); + b.emitBeat({ type: "validate", check: "lint", status: "running" }); + await sleep(1200); + b.emitBeat({ type: "validate", check: "lint", status: "ok" }); + b.emitBeat({ type: "validate", check: "types", status: "ok" }); + b.emitBeat({ type: "validate", check: "tests", status: "running" }); + await sleep(1800); + + process.stderr.write("[demo] waiting for your approach choice…\n"); + const choice = await b.presentOptions("The agent needs your call — which approach?", [ + { id: "a", title: "Minimal patch", detail: "Guard the handler in place. Ships today." }, + { id: "b", title: "Token middleware", detail: "Reusable middleware layer. Clean seam.", recommended: true }, + { id: "c", title: "Full rewrite", detail: "Policy engine. Most durable, higher risk." }, + ]); + process.stderr.write(`[demo] you chose: ${choice}\n`); + await sleep(800); + + b.emitBeat({ type: "validate", check: "tests", status: "ok" }); + b.emitBeat({ type: "validate", check: "build", status: "ok" }); + b.emitBeat({ type: "subagent.stop", name: "Phase Implementor", result: `implemented option ${choice}` }); + b.emitBeat({ type: "artifact.update", path: ".copilot-tracking/changes/auth-changes.md", summary: "+64 / -12" }); + await sleep(1600); + + b.emitBeat({ type: "phase.enter", phase: "review" }); + await sleep(2000); + b.emitBeat({ type: "phase.enter", phase: "discover" }); + await sleep(1200); + + process.stderr.write("[demo] waiting for your next-work choice…\n"); + const next = await b.presentOptions("Suggested next work — what should I pick up?", [ + { id: "1", title: "Add refresh-token rotation", detail: "Follows from the new middleware.", recommended: true }, + { id: "2", title: "Backfill auth tests", detail: "Close the coverage gap on changed paths." }, + { id: "3", title: "Document the auth flow", detail: "Update the architecture notes." }, + ]); + process.stderr.write(`[demo] next work: ${next}\n[demo] session complete — re-run for another pass.\n`); +} + +run().catch((e) => process.stderr.write(`[demo] error: ${e?.message ?? e}\n`)); diff --git a/rpi-cockpit/package-lock.json b/rpi-cockpit/package-lock.json new file mode 100644 index 000000000..12351e727 --- /dev/null +++ b/rpi-cockpit/package-lock.json @@ -0,0 +1,3178 @@ +{ + "name": "rpi-cockpit", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rpi-cockpit", + "version": "0.1.0", + "dependencies": { + "@modelcontextprotocol/sdk": "1.29.0", + "ws": "8.21.0", + "zod": "3.25.76" + }, + "bin": { + "rpi-cockpit": "dist/index.js" + }, + "devDependencies": { + "@types/node": "22.20.0", + "@types/ws": "8.18.1", + "happy-dom": "15.11.7", + "tsx": "4.22.4", + "typescript": "5.9.3", + "vitest": "2.1.9" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/happy-dom": { + "version": "15.11.7", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.11.7.tgz", + "integrity": "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^4.5.0", + "webidl-conversions": "^7.0.0", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.27", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", + "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/rpi-cockpit/package.json b/rpi-cockpit/package.json new file mode 100644 index 000000000..4e6ba50f5 --- /dev/null +++ b/rpi-cockpit/package.json @@ -0,0 +1,29 @@ +{ + "name": "rpi-cockpit", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { "rpi-cockpit": "dist/index.js" }, + "scripts": { + "test": "vitest run", + "dev": "tsx src/index.ts", + "preview": "node preview.mjs", + "build": "tsc", + "postbuild": "chmod +x dist/index.js", + "prepare": "npm run build" + }, + "engines": { "node": ">=20" }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.29.0", + "ws": "8.21.0", + "zod": "3.25.76" + }, + "devDependencies": { + "@types/node": "22.20.0", + "@types/ws": "8.18.1", + "happy-dom": "15.11.7", + "tsx": "4.22.4", + "typescript": "5.9.3", + "vitest": "2.1.9" + } +} diff --git a/rpi-cockpit/preview.mjs b/rpi-cockpit/preview.mjs new file mode 100644 index 000000000..f1473602d --- /dev/null +++ b/rpi-cockpit/preview.mjs @@ -0,0 +1,112 @@ +// rpi-cockpit/preview.mjs +// Committed preview / dev harness. The host pane (Claude Preview, VS Code) sets +// PORT and loads the bare root, so we start the server in embed mode +// (trustLoopback: true). With no real agent driving beats, we run a LIVE TOUR: +// a persistent context strip, then a walk through every loop view (RPI build, +// reviewers findings, guided interview, backlog kanban), ending at rest on the +// board. This lets the pane show the whole cockpit live, with no synthetic +// client-side injection. The decision and question cards are shown with a finite +// timeout so they auto-resolve and nothing is left dangling. The live +// agent-driven feed is the real path; this is the dev/preview harness only. +// Requires a prior `npm run build`. +import { Bridge } from "./dist/bridge.js"; +import { startServer } from "./dist/server.js"; +import { resolvePort } from "./dist/port.js"; + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +const b = new Bridge(); +const port = resolvePort(process.env); +const srv = await startServer(b, port, { trustLoopback: true }); +process.stderr.write(`rpi-cockpit preview at ${srv.url} (live tour)\n`); + +const HOLD = 4200; // how long a card or view rests before the tour moves on +const GAP = 1400; // a breath between scenes + +// Ambient context first: the strip persists across every view in the tour. +b.emitBeat({ + type: "context.set", + instructions: ["typescript", "writing-style", "testing"], + skills: ["systematic-debugging", "subagent-driven-development"], + collection: "hve-core", +}); + +setTimeout(() => { + runTour().catch((e) => process.stderr.write(`preview tour error: ${e?.message ?? e}\n`)); +}, 800); + +async function runTour() { + await rpiScene(); + await sleep(GAP); + await reviewScene(); + await sleep(GAP); + await interviewScene(); + await sleep(GAP); + await backlogScene(); + process.stderr.write("rpi-cockpit preview: tour complete, resting on the backlog board\n"); +} + +async function rpiScene() { + b.emitBeat({ type: "session.begin", task: "Add token-rotation middleware", host: "claude-code" }); + await sleep(600); + b.emitBeat({ type: "phase.enter", phase: "research" }); + b.emitBeat({ type: "subagent.start", name: "Researcher Subagent", role: "scanning the codebase" }); + await sleep(900); + b.emitBeat({ type: "subagent.stop", name: "Researcher Subagent", result: "mapped the auth layer" }); + b.emitBeat({ type: "phase.enter", phase: "plan" }); + await sleep(800); + b.emitBeat({ type: "phase.enter", phase: "implement" }); + b.emitBeat({ type: "subagent.start", name: "Phase Implementor", role: "applying the change" }); + b.emitBeat({ type: "validate", check: "lint", status: "ok" }); + b.emitBeat({ type: "validate", check: "types", status: "ok" }); + b.emitBeat({ type: "validate", check: "tests", status: "running" }); + await sleep(900); + // The decision card, shown then auto-resolved to the recommended option. + await b.presentOptions("Which rotation strategy?", [ + { id: "a", title: "Sliding window", detail: "Refresh the token on each use." }, + { id: "b", title: "Fixed TTL", detail: "Rotate on a timer, simplest to reason about.", recommended: true }, + ], HOLD); + b.emitBeat({ type: "validate", check: "tests", status: "ok" }); + b.emitBeat({ type: "subagent.stop", name: "Phase Implementor", result: "middleware landed" }); +} + +async function reviewScene() { + b.emitBeat({ type: "review.start", target: "PR #128: token-rotation middleware" }); + await sleep(500); + b.emitBeat({ type: "finding.add", severity: "critical", title: "Refresh token logged in plaintext", file: "src/auth/rotate.ts", line: 42, detail: "The new token is written to the debug log before hashing." }); + b.emitBeat({ type: "finding.add", severity: "high", title: "Missing expiry check on reuse", file: "src/auth/rotate.ts", line: 71 }); + b.emitBeat({ type: "finding.add", severity: "medium", title: "Magic number for the TTL", file: "src/auth/config.ts", line: 18, detail: "Extract 3600 into a named constant." }); + b.emitBeat({ type: "finding.add", severity: "low", title: "Doc comment is stale", file: "src/auth/rotate.ts", line: 5 }); + await sleep(HOLD); +} + +async function interviewScene() { + b.emitBeat({ type: "interview.start", docType: "PRD" }); + await sleep(400); + b.emitBeat({ + type: "screen.show", + title: "PRD draft", + html: "

PRD: Token rotation

Problem. Long-lived tokens widen the blast radius of a leak.

Goal. Rotate tokens automatically without logging the user out.

Filling in: primary user goal…

", + }); + // The question card, shown then auto-resolved (empty) so it clears. + await b.askQuestion("What is the primary user goal for token rotation?", HOLD); + b.clearScreen(); +} + +async function backlogScene() { + b.emitBeat({ type: "backlog.start", target: "Sprint 24", columns: ["Triage", "Todo", "In progress", "In review", "Done"] }); + await sleep(400); + b.emitBeat({ type: "item.add", id: "#312", title: "Login throttle returns 500 on burst", column: "Triage", kind: "bug", tier: "ask" }); + b.emitBeat({ type: "item.add", id: "#298", title: "Add audit log export endpoint", column: "Todo", kind: "feature", tier: "auto" }); + b.emitBeat({ type: "item.add", id: "#305", title: "Upgrade the pg driver to v8", column: "Todo", kind: "chore" }); + b.emitBeat({ type: "item.add", id: "#287", title: "Token rotation middleware", column: "In progress", kind: "feature", tier: "auto" }); + b.emitBeat({ type: "item.add", id: "#270", title: "Fix flaky cache eviction test", column: "Done", kind: "bug" }); + b.emitBeat({ type: "backlog.action", text: "Triaging #312" }); + await sleep(HOLD); + // Show movement: triage resolves, an item advances. + b.emitBeat({ type: "item.move", id: "#312", column: "Todo" }); + b.emitBeat({ type: "item.move", id: "#287", column: "In review" }); + b.emitBeat({ type: "backlog.action", text: "Planning the next pull" }); +} + +// Keep the process (and the server) alive so the pane stays connected. +setInterval(() => {}, 1 << 30); diff --git a/rpi-cockpit/public/client.js b/rpi-cockpit/public/client.js new file mode 100644 index 000000000..2eb5292b4 --- /dev/null +++ b/rpi-cockpit/public/client.js @@ -0,0 +1,1123 @@ +// rpi-cockpit/public/client.js +// Thin painter: every value comes from the server's view model (src/render.ts). +const LABEL = { research: "Research", plan: "Plan", implement: "Implement", review: "Review", discover: "Discover" }; +const WF_ICON = { build: "", review: "✓", plan: "▦", docs: "▤", data: "▥", coach: "✷" }; + +// Client mirror of src/url.ts isLoopbackHttpUrl. The app frame is a TRUSTED iframe +// (scripts + the app's own origin), so its URL is constrained to a loopback http(s) +// origin. The server already rejects non-loopback URLs at the MCP tool boundary; +// this re-check before assigning the iframe src is defense in depth. Keep this in +// lockstep with the TS predicate. +function isLoopbackHttpUrl(u) { + try { + const url = new URL(u); + if (url.protocol !== "http:" && url.protocol !== "https:") return false; + const h = url.hostname.toLowerCase(); + return h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "::1"; + } catch { + return false; + } +} + +// Client mirror of src/url.ts isGalleryUrl. Gallery tiles may frame loopback dev +// servers (http or https) and external https sites; external http is rejected. +// Re-checked here before assigning an iframe src (defense in depth). Keep this in +// lockstep with the TS predicate. +function isGalleryUrl(u) { + try { + const url = new URL(u); + if (url.protocol !== "http:" && url.protocol !== "https:") return false; + if (isLoopbackHttpUrl(u)) return true; + return url.protocol === "https:"; + } catch { + return false; + } +} + +function renderNavTiles(v) { + setHtml("nav-workflows", (v.workflows || []).map((w) => + `
+
${esc(WF_ICON[w.id] || "•")}
+
${esc(w.name)}
+
${esc(w.hint)}
+
${esc(w.description)}
+
`).join("")); +} + +function renderContext(v) { + const c = v.context || { instructions: [], skills: [], collection: null }; + const strip = document.getElementById("context-strip"); + if (!strip) return; + const chip = (t, cls) => `${esc(t)}`; + const group = (id, chips) => { + const el = document.getElementById(id); + if (!el) return false; + const has = chips.length > 0; + el.hidden = !has; + if (has) el.querySelector(".ctx-chips").innerHTML = chips.join(""); + return has; + }; + const gi = group("ctx-instructions", (c.instructions || []).map((t) => chip(t))); + const gs = group("ctx-skills", (c.skills || []).map((t) => chip(t))); + const gc = group("ctx-collection", c.collection ? [chip(c.collection, "collection")] : []); + strip.hidden = !(gi || gs || gc); +} + +// App frame panel (#app-frame). SECURITY: the app frame is the TRUSTED sibling of +// the sandboxed screen pane. The iframe's sandbox is fixed in index.html to +// "allow-scripts allow-same-origin allow-forms" (no top-navigation, no popups); +// because the framed app runs on its own port it is cross-origin to the cockpit, +// so allow-same-origin grants the app its OWN origin, never the cockpit's. We +// re-check the loopback predicate here before assigning src as defense in depth: +// the server already rejected non-loopback URLs at the tool boundary, but a stale +// or tampered view-model URL must never reach the iframe. The URL text lands in +// the cockpit DOM via setText (escaped), and src is set via setAttribute only +// after the check passes. +function renderAppFrame(v) { + const panel = document.getElementById("app-frame"); + const iframe = document.getElementById("af-iframe"); + if (!panel || !iframe) return; + const url = v.appFrame && v.appFrame.url; + if (url && isLoopbackHttpUrl(url)) { + panel.hidden = false; + setText("af-url", url); + if (iframe.getAttribute("src") !== url) iframe.setAttribute("src", url); + } else { + panel.hidden = true; + if (iframe.getAttribute("src")) iframe.removeAttribute("src"); + setText("af-url", ""); + } +} + +let ws = null; +let backoff = 500; +// Tracks the rendered codemap node-id signature so renderCodemap rebuilds the +// node elements only when the node set changes (build-once); focus/touch updates +// then mutate classes + the camera transform in place so the camera glides. (codemap) +let cmSig = null; +// Gallery view state: the current items (for lightbox lookup by index) and the +// user's S/M/L size override (sticky across re-renders until the server sends a +// new gallery.open size). (gallery) +let glItems = []; +let glSizeOverride = null; +let glResizeRaf = 0; +// Promptlab: which case rows are expanded, keyed by the case's data-pc value (its +// stable case id). The expanded state lives here (not read back from the DOM) so a +// click flips it and writes the row's class in one shot. (promptlab) +const plOpen = new Set(); +// Memory: which entry rows are expanded, keyed by the entry's data-me id (the same +// id-keyed Set + per-render reconcile + document re-scan pattern the promptlab case +// rows use, because happy-dom's eval harness does not reflect parentElement mutations). +const meOpen = new Set(); + +// Flow canvas camera + interaction state. The agent narrates topology (v.flow); the client +// lays it out (computeFlowLayout), renders cards into #gw-world, and applies a 2D camera. +let gwCam = { x: 40, y: 40, z: 1 }; +let gwFocusOverride = undefined; // undefined = follow server; string|null = local drill +let gwServerFocus = null; // last server focus seen, to detect new narration +let gwSel = null; // selected node id +let gwPos = {}, gwNodes = []; // active layout positions + nodes (for inspector + minimap) +let gwFitSig = null; // active scope + node-set signature; re-fit the camera when it changes +const GW_GLYPH = { workflow: "▦", trigger: "⊙", guard: "⚿", agent: "✦", output: "▣", mcp: "⚙" }; + +// Last view-model rendered. The flow drill-in/select/clear handlers re-render the +// flow scope by calling renderFlow(lastView); without a retained view-model they +// would have nothing to rebuild from. Set at the top of render(v). +let lastView = null; + +function connect() { + setConn("connecting"); + // The per-session token usually rides the same-origin cookie set when this page + // loaded via /?key=…. Forward it explicitly from the URL too, so the WS handshake + // authenticates even before/without that cookie. + const key = new URLSearchParams(location.search).get("key"); + const suffix = key ? `/?key=${encodeURIComponent(key)}` : ""; + ws = new WebSocket(`ws://${location.host}${suffix}`); + ws.onopen = () => { backoff = 500; }; + ws.onmessage = (e) => { + let msg; + try { msg = JSON.parse(e.data); } catch { return; } + if (msg.type === "state" && msg.view) { setConn("live"); render(msg.view); } + }; + ws.onclose = () => { setConn("offline"); setTimeout(connect, backoff); backoff = Math.min(backoff * 2, 8000); }; + ws.onerror = () => { try { ws.close(); } catch (_) {} }; +} + +function setConn(status) { + const pill = document.getElementById("conn-pill"); + if (pill) pill.dataset.status = status; + setText("conn-label", status === "live" ? "live" : status === "offline" ? "offline" : "connecting…"); +} + +function renderHome(v) { + const wf = v.activeWorkflow ? (v.workflows.find((w) => w.id === v.activeWorkflow) || null) : null; + const running = v.started || !!v.activeWorkflow; + setHtml("orient", running + ? `${esc((wf && wf.name) || v.task || "A loop")} is running. ` + : `Nothing running yet. Ask in chat to start a workflow.`); + const welcome = document.getElementById("welcome"); + if (welcome) welcome.hidden = localStorage.getItem("hve-welcome-dismissed") === "1"; + const status = document.getElementById("home-status"); + if (status) status.textContent = running ? "Loop running" : "No loop running"; +} + +function render(v) { + lastView = v; + renderContext(v); + renderAppFrame(v); + renderNavTiles(v); + renderDecisionFlow(v); + if (v.navigatorOpen) { const w = document.getElementById("welcome"); if (w) w.hidden = false; } + + const home = document.getElementById("home"); + const loop = document.getElementById("loop"); + if (home && loop) { + const onHome = v.view === "home"; + home.hidden = !onHome; + loop.hidden = onHome; + const toHomeBtn = document.getElementById("to-home"); + if (toHomeBtn) toHomeBtn.hidden = onHome; + if (onHome) { renderHome(v); return; } + } + + const rpiView = document.getElementById("rpi-view"); + const findingsView = document.getElementById("findings-view"); + const interviewView = document.getElementById("interview-view"); + const backlogView = document.getElementById("backlog-view"); + const teamView = document.getElementById("team-view"); + const codemapView = document.getElementById("codemap-view"); + const dataprofileView = document.getElementById("dataprofile-view"); + const galleryView = document.getElementById("gallery-view"); + const promptlabView = document.getElementById("promptlab-view"); + const memoryView = document.getElementById("memory-view"); + const flowView = document.getElementById("flow-view"); + if (rpiView && findingsView) { + if (v.domain === "codemap") { + rpiView.hidden = true; findingsView.hidden = true; + if (interviewView) interviewView.hidden = true; + if (backlogView) backlogView.hidden = true; + if (teamView) teamView.hidden = true; + if (codemapView) codemapView.hidden = false; + if (dataprofileView) dataprofileView.hidden = true; + if (galleryView) galleryView.hidden = true; + if (promptlabView) promptlabView.hidden = true; + if (memoryView) memoryView.hidden = true; + if (flowView) flowView.hidden = true; + renderCodemap(v); + return; + } + if (v.domain === "team") { + rpiView.hidden = true; findingsView.hidden = true; + if (interviewView) interviewView.hidden = true; + if (backlogView) backlogView.hidden = true; + if (teamView) teamView.hidden = false; + if (codemapView) codemapView.hidden = true; + if (dataprofileView) dataprofileView.hidden = true; + if (galleryView) galleryView.hidden = true; + if (promptlabView) promptlabView.hidden = true; + if (memoryView) memoryView.hidden = true; + if (flowView) flowView.hidden = true; + renderTeam(v); + return; + } + if (v.domain === "backlog") { + rpiView.hidden = true; findingsView.hidden = true; + if (interviewView) interviewView.hidden = true; + if (backlogView) backlogView.hidden = false; + if (teamView) teamView.hidden = true; + if (codemapView) codemapView.hidden = true; + if (dataprofileView) dataprofileView.hidden = true; + if (galleryView) galleryView.hidden = true; + if (promptlabView) promptlabView.hidden = true; + if (memoryView) memoryView.hidden = true; + if (flowView) flowView.hidden = true; + renderBoard(v); + return; + } + if (v.domain === "dataprofile") { + rpiView.hidden = true; findingsView.hidden = true; + if (interviewView) interviewView.hidden = true; + if (backlogView) backlogView.hidden = true; + if (teamView) teamView.hidden = true; + if (codemapView) codemapView.hidden = true; + if (dataprofileView) dataprofileView.hidden = false; + if (galleryView) galleryView.hidden = true; + if (promptlabView) promptlabView.hidden = true; + if (memoryView) memoryView.hidden = true; + if (flowView) flowView.hidden = true; + renderDataProfile(v); + return; + } + if (v.domain === "gallery") { + rpiView.hidden = true; findingsView.hidden = true; + if (interviewView) interviewView.hidden = true; + if (backlogView) backlogView.hidden = true; + if (teamView) teamView.hidden = true; + if (codemapView) codemapView.hidden = true; + if (dataprofileView) dataprofileView.hidden = true; + if (galleryView) galleryView.hidden = false; + if (promptlabView) promptlabView.hidden = true; + if (memoryView) memoryView.hidden = true; + if (flowView) flowView.hidden = true; + renderGallery(v); + return; + } + if (v.domain === "promptlab") { + rpiView.hidden = true; findingsView.hidden = true; + if (interviewView) interviewView.hidden = true; + if (backlogView) backlogView.hidden = true; + if (teamView) teamView.hidden = true; + if (codemapView) codemapView.hidden = true; + if (dataprofileView) dataprofileView.hidden = true; + if (galleryView) galleryView.hidden = true; + if (promptlabView) promptlabView.hidden = false; + if (memoryView) memoryView.hidden = true; + if (flowView) flowView.hidden = true; + renderPromptlab(v); + return; + } + if (v.domain === "flow") { + rpiView.hidden = true; findingsView.hidden = true; + if (interviewView) interviewView.hidden = true; + if (backlogView) backlogView.hidden = true; + if (teamView) teamView.hidden = true; + if (codemapView) codemapView.hidden = true; + if (dataprofileView) dataprofileView.hidden = true; + if (galleryView) galleryView.hidden = true; + if (promptlabView) promptlabView.hidden = true; + if (memoryView) memoryView.hidden = true; + if (flowView) flowView.hidden = false; + renderFlow(v); + return; + } + if (v.domain === "memory") { + rpiView.hidden = true; findingsView.hidden = true; + if (interviewView) interviewView.hidden = true; + if (backlogView) backlogView.hidden = true; + if (teamView) teamView.hidden = true; + if (codemapView) codemapView.hidden = true; + if (dataprofileView) dataprofileView.hidden = true; + if (galleryView) galleryView.hidden = true; + if (promptlabView) promptlabView.hidden = true; + if (memoryView) memoryView.hidden = false; + if (flowView) flowView.hidden = true; + renderMemory(v); + return; + } + if (v.domain === "interview") { + rpiView.hidden = true; findingsView.hidden = true; + if (interviewView) interviewView.hidden = false; + if (backlogView) backlogView.hidden = true; + if (teamView) teamView.hidden = true; + if (codemapView) codemapView.hidden = true; + if (dataprofileView) dataprofileView.hidden = true; + if (galleryView) galleryView.hidden = true; + if (promptlabView) promptlabView.hidden = true; + if (memoryView) memoryView.hidden = true; + if (flowView) flowView.hidden = true; + renderInterview(v); + return; + } + const review = v.domain === "review"; + rpiView.hidden = review; + findingsView.hidden = !review; + if (interviewView) interviewView.hidden = true; + if (backlogView) backlogView.hidden = true; + if (teamView) teamView.hidden = true; + if (codemapView) codemapView.hidden = true; + if (dataprofileView) dataprofileView.hidden = true; + if (galleryView) galleryView.hidden = true; + if (promptlabView) promptlabView.hidden = true; + if (memoryView) memoryView.hidden = true; + if (flowView) flowView.hidden = true; + if (review) { renderFindings(v); return; } + } + + setText("crumb-task", v.task || "—"); + setText("phase-title", v.phaseNumber ? `Phase ${v.phaseNumber} · ${v.phaseLabel}` : "RPI session"); + setText("phase-state", v.phase ? "● running" : ""); + setText("lead", v.lead); + const host = document.getElementById("host-pill"); + if (host) { host.textContent = `via MCP · ${v.host}`; host.hidden = !v.host; } + + setHtml("steps", v.steps.map((st, i) => + `
${st.status === "done" ? "✓" : i + 1}
+
${i + 1} · ${LABEL[st.phase] ?? esc(st.phase)}
${i < v.steps.length - 1 ? '
' : ""}
`).join("")); + + setHtml("subagents", v.subagents.length + ? v.subagents.map((a) => + `
${initials(a.name)}
+
${esc(a.name)}
${esc(a.role ?? "")}
+ ${esc(a.status)}
`).join("") + : `
No subagents yet.
`); + + setHtml("gate", v.validations.map(({ check, status }) => { + const cls = status === "ok" ? "ok" : status === "running" ? "run" : status === "fail" ? "fail" : "wait"; + const mark = status === "ok" ? "✓" : status === "running" ? "●" : status === "fail" ? "✕" : "○"; + return `${mark} ${esc(check)}`; + }).join("") || `○ no checks yet`); + + const sel = document.getElementById("steer-select"); + if (sel) { + setText("steer-label", v.steerMenu.label); + const cur = sel.value; + sel.innerHTML = v.steerMenu.options.map((o) => ``).join(""); + if (cur) sel.value = cur; + } + + setHtml("directives", v.directives.map((d) => + `
queued ${esc(d.kind === "note" ? d.text : d.label)} · applies at next checkpoint
`).join("")); + + renderScreen(v.screen); + + const stream = document.querySelector(".stream"); + if (stream) stream.innerHTML = v.log.slice(-12).map((l) => + `
${new Date(l.t).toLocaleTimeString().slice(0, 5)} + ${esc(l.kind)} ${esc(l.detail)}
`).join(""); +} + +// Agent-authored screen pane. SECURITY: the agent HTML renders inside an iframe +// whose `sandbox` attribute is set to the empty string — the maximally restrictive +// value: scripts are disabled and the frame gets a unique opaque origin. So the +// HTML is inert (no JS) and isolated from the cockpit (no cookie/token/DOM reach +// into the parent). We assign it via `srcdoc` (NOT escaped — the sandbox is the +// boundary, escaping would break legitimate markup); only the title is escaped, +// since that text lands in the cockpit's own DOM. We rebuild the iframe each time +// so a cleared screen leaves no live frame behind. +function renderScreen(screen) { + const pane = document.getElementById("screen"); + if (!pane) return; + if (!screen) { pane.hidden = true; pane.innerHTML = ""; return; } + pane.hidden = false; + const title = document.createElement("div"); + title.className = "sec"; + title.id = "screen-title"; + title.innerHTML = esc(screen.title || "Screen"); + const frame = document.createElement("div"); + frame.className = "screen-frame"; + const iframe = document.createElement("iframe"); + iframe.id = "screen-iframe"; + iframe.title = "Agent screen"; + // Empty value = no allow-scripts, no allow-same-origin. Do NOT loosen this. + iframe.setAttribute("sandbox", ""); + iframe.srcdoc = screen.html; + frame.appendChild(iframe); + pane.replaceChildren(title, frame); +} + +function renderDecisionFlow(v) { + const flow = document.getElementById("decision-flow"); + if (!flow) return; + const ds = v.decisions || []; + if (ds.length === 0) { flow.hidden = true; flow.innerHTML = ""; return; } + flow.hidden = false; + const interactive = !v.hostElicits; // pane is a fallback input only when chat can't elicit + flow.innerHTML = ds.map((d) => { + const chips = d.kind === "choice" && d.options ? `
${d.options.map((o) => + `${esc(o.title)}`).join("")}
` : ""; + const answer = d.kind === "text" && d.answer ? `
${esc(d.answer)}
` : ""; + const pendingHint = d.status === "pending" ? `
awaiting your answer in chat
` : ""; + const revise = d.status === "answered" ? `` : ""; + return `
+
${esc(d.prompt)}
${answer}${chips}${pendingHint}${revise}
`; + }).join(""); + // Host the flow in the active view's slot (RPI center / interview), else leave in #loop. + const slot = document.querySelector(v.domain === "rpi" ? "#rpi-view .center .flow-slot" : v.domain === "interview" ? "#interview-view .flow-slot" : null); + if (slot && flow.parentElement !== slot) slot.appendChild(flow); +} + +// Event delegation: home interactions + decision buttons + steer "Queue directive" button. +document.addEventListener("click", (e) => { + const iv = e.target.closest("[data-intervene]"); + if (iv) { sendMsg({ type: "intervene", action: iv.dataset.intervene, agentId: iv.dataset.agent }); return; } + const gsize = e.target.closest(".gl-size[data-gsize]"); + if (gsize) { + // Every size renders thumbnails, so a toggle is just a class swap plus a re-scale to the + // new tile width (no rebuild). S/M are multi-column; L is one full-width column. + glSizeOverride = gsize.dataset.gsize; + const grid = document.getElementById("gl-grid"); + if (grid) grid.className = `gsize-${glSizeOverride}`; + document.querySelectorAll(".gl-size").forEach((b) => b.classList.toggle("active", b.dataset.gsize === glSizeOverride)); + sizeGalleryThumbs(); + return; + } + if (e.target.closest("#gl-lb-close")) { closeLightbox(); return; } + if (e.target.id === "gl-lightbox") { closeLightbox(); return; } // backdrop + if (e.target.closest("[data-noexpand]")) return; // let open-in-tab work + const glCardEl = e.target.closest(".gl-card[data-gl]"); + if (glCardEl) { openLightbox(+glCardEl.dataset.gl); return; } + const pcHead = e.target.closest(".pc-head"); + if (pcHead && pcHead.parentElement) { + // Toggle the case row's expanded state. We resolve the live .pc-case from a fresh + // document scan (matching its stable data-pc id) and write its class in a single + // assignment from plOpen, rather than mutating pcHead.parentElement / calling + // classList.toggle: in a real browser both resolve to the same node and either + // works, but under the happy-dom test harness the event-target node is a separate + // object graph (mutations don't reflect) and DOMTokenList.toggle on a queried node + // is unreliable, so a single className write from our own state is the robust path. + // We match by getAttribute (not a built selector) so an exotic id cannot malform it. + const k = pcHead.parentElement.getAttribute("data-pc"); + if (k != null) { + if (plOpen.has(k)) plOpen.delete(k); else plOpen.add(k); + const cls = plOpen.has(k) ? "pc-case open" : "pc-case"; + document.querySelectorAll(".pc-case").forEach((el) => { if (el.getAttribute("data-pc") === k) el.className = cls; }); + } + return; + } + const meHead = e.target.closest(".me-head"); + if (meHead && meHead.parentElement) { + const k = meHead.parentElement.getAttribute("data-me"); + if (k != null) { + if (meOpen.has(k)) meOpen.delete(k); else meOpen.add(k); + const cls = meOpen.has(k) ? "me-entry open" : "me-entry"; + document.querySelectorAll(".me-entry").forEach((el) => { if (el.getAttribute("data-me") === k) el.className = cls; }); + } + return; + } + const gwBack = e.target.closest("#gw-back"); + if (gwBack) { gwFocusOverride = null; const w = lastView; if (w) renderFlow(w); return; } + const gwNode = e.target.closest(".gw-node[data-gw]"); + if (gwNode) { + const id = gwNode.getAttribute("data-gw"); + gwSel = id; + if (gwNode.getAttribute("data-kind") === "workflow") gwFocusOverride = id; // drill in + if (lastView) renderFlow(lastView); + return; + } + const gwMini = e.target.closest("#gw-minimap"); + if (gwMini) { + const r = gwMini.getBoundingClientRect(); + const s = parseFloat(gwMini.dataset.scale || "1"), pad = parseFloat(gwMini.dataset.pad || "8"); + const wx = (e.clientX - r.left - pad) / s, wy = (e.clientY - r.top - pad) / s; + const c = document.getElementById("gw-canvas").getBoundingClientRect(); + gwCam.x = c.width / 2 - wx * gwCam.z; gwCam.y = c.height / 2 - wy * gwCam.z; gwApplyCam(); gwRenderMinimap(); + return; + } + const gwBg = e.target.closest("#gw-canvas"); + if (gwBg && !e.target.closest(".gw-node")) { if (gwSel !== null) { gwSel = null; if (lastView) renderFlow(lastView); } /* fallthrough to allow pan */ } + if (e.target.closest("#to-home")) { sendMsg({ type: "navigate", screen: "home" }); return; } + if (e.target.closest("#to-loop")) { sendMsg({ type: "navigate", screen: "loop" }); return; } + if (e.target.closest("#help-btn")) { const w = document.getElementById("welcome"); if (w) w.hidden = false; return; } + if (e.target.closest("#welcome-dismiss")) { + // Clear the server flag too — open_navigator set navigatorOpen:true, and + // render() reopens the overlay on every state push until the server flips it + // back. The localStorage write only suppresses the first-run welcome. (A2) + localStorage.setItem("hve-welcome-dismissed", "1"); + sendMsg({ type: "navigator", open: false }); + const w = document.getElementById("welcome"); if (w) w.hidden = true; + return; + } + const tile = e.target.closest("[data-launch]"); + if (tile) { launchWorkflow(tile.dataset.launch); return; } + const rev = e.target.closest("[data-revise]"); + if (rev) { sendMsg({ type: "revise", id: rev.dataset.revise }); return; } + const fchoice = e.target.closest("#decision-flow [data-choice]"); + if (fchoice) { sendMsg({ type: "decide", id: fchoice.dataset.id, choiceId: fchoice.dataset.choice }); return; } + const loc = e.target.closest(".finding-loc[data-loc]"); + if (loc) { copyLoc(loc); return; } + const open = e.target.closest(".finding-open[data-file]"); + if (open) { + const file = open.dataset.file; + const line = open.dataset.line; + sendMsg(line != null ? { type: "open", file, line: Number(line) } : { type: "open", file }); + return; + } + if (e.target.closest("#steer-send")) { + const note = document.getElementById("steer-note"); + const text = (note && note.value || "").trim(); + if (text) { sendMsg({ type: "steer", directive: { kind: "note", text } }); note.value = ""; return; } + const sel = document.getElementById("steer-select"); + if (sel && sel.value) { + // The agent reads the rendered label (see summarizeDirective); `value` is advisory, + // not a stable enum to switch on (preset ids carry no session meaning). + const opt = sel.options[sel.selectedIndex]; + sendMsg({ type: "steer", directive: { kind: "approach", value: sel.value, label: opt ? opt.textContent : sel.value } }); + } + } +}); + +// Keyboard access for the workflow tiles (role=button, tabindex=0): Enter/Space +// activate the focused tile the same as a click. Space is prevented so it does +// not scroll the overlay. (C1) +document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + const lb = document.getElementById("gl-lightbox"); + if (lb && !lb.hidden) { closeLightbox(); return; } + } + if (e.key !== "Enter" && e.key !== " " && e.key !== "Spacebar") return; + const tile = e.target.closest && e.target.closest("[data-launch]"); + if (!tile) return; + e.preventDefault(); + launchWorkflow(tile.dataset.launch); +}); + +// Re-scale gallery thumbnails to their (fluid) tile width when the viewport changes. +window.addEventListener("resize", () => { + if (glResizeRaf) return; + glResizeRaf = requestAnimationFrame(() => { glResizeRaf = 0; sizeGalleryThumbs(); }); +}); + +// Flow canvas camera: pan by dragging the canvas background, wheel to zoom around the +// cursor. Delegated document listeners wired once; the world transform lives in gwCam. +(function gwCameraWiring() { + let dragging = false, lx = 0, ly = 0; + document.addEventListener("pointerdown", (e) => { + const canvas = e.target.closest && e.target.closest("#gw-canvas"); + if (!canvas || e.target.closest(".gw-node") || e.target.closest("#gw-minimap")) return; + dragging = true; lx = e.clientX; ly = e.clientY; canvas.classList.add("grabbing"); + }); + document.addEventListener("pointermove", (e) => { + if (!dragging) return; + gwCam.x += e.clientX - lx; gwCam.y += e.clientY - ly; lx = e.clientX; ly = e.clientY; gwApplyCam(); + }); + document.addEventListener("pointerup", () => { dragging = false; const c = document.getElementById("gw-canvas"); if (c) c.classList.remove("grabbing"); }); + document.addEventListener("wheel", (e) => { + const canvas = e.target.closest && e.target.closest("#gw-canvas"); + if (!canvas) return; + e.preventDefault(); + const r = canvas.getBoundingClientRect(); + const mx = e.clientX - r.left, my = e.clientY - r.top; + const nz = Math.min(2, Math.max(0.3, gwCam.z * (e.deltaY < 0 ? 1.1 : 1 / 1.1))); + // zoom around cursor: keep the world point under the cursor fixed + gwCam.x = mx - (mx - gwCam.x) * (nz / gwCam.z); + gwCam.y = my - (my - gwCam.y) * (nz / gwCam.z); + gwCam.z = nz; gwApplyCam(); + }, { passive: false }); +})(); + +// Launch a workflow and close the Navigator: tell the server to clear +// navigatorOpen (startLaunch already does, but the explicit frame is belt-and- +// suspenders if the launch path ever changes) and hide the overlay locally. (A2/C1) +function launchWorkflow(workflowId) { + sendMsg({ type: "launch", workflowId }); + sendMsg({ type: "navigator", open: false }); + const w = document.getElementById("welcome"); if (w) w.hidden = true; +} + +function sendMsg(m) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(m)); } +const setText = (id, t) => { const el = document.getElementById(id); if (el) el.textContent = t; }; +const setHtml = (id, h) => { const el = document.getElementById(id); if (el) el.innerHTML = h; }; +const initials = (n) => (n || "?").split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase(); +const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); +const kindCls = (k) => k.startsWith("directive") ? "s2" : k === "validate" ? "ok" : ""; + +const SEV_LABEL = { critical: "Critical", high: "High", medium: "Medium", low: "Low", info: "Info" }; + +function copyLoc(btn) { + const text = btn.dataset.loc; + try { if (navigator.clipboard) navigator.clipboard.writeText(text); } catch { /* clipboard unavailable */ } + btn.textContent = "copied"; + setTimeout(() => { btn.textContent = text; }, 1200); +} + +function renderFindings(v) { + setText("rev-target", v.reviewTarget || "Review"); + const total = v.findingGroups.reduce((n, g) => n + g.items.length, 0); + setText("rev-counts", total === 1 ? "1 finding" : `${total} findings`); + const pipe = document.getElementById("rev-pipeline"); + if (pipe) { + const subs = v.subagents || []; + if (subs.length) { + pipe.hidden = false; + pipe.innerHTML = `
Live reviewers
` + subs.map((a) => + `
${initials(a.name)}
+
${esc(a.name)}
${esc(a.role ?? "")}
+ ${esc(a.status)}
`).join(""); + } else { pipe.hidden = true; pipe.innerHTML = ""; } + } + setHtml("findings", v.findingGroups.map((g) => + `
+
${esc(SEV_LABEL[g.severity] || g.severity)} (${g.items.length})
+ ${g.items.map((f) => { + const loc = f.file ? esc(f.file) + (f.line != null ? ":" + esc(String(f.line)) : "") : ""; + return `
+
+ ${esc(f.title)} + ${f.file ? `` : ""} +
+ ${f.detail ? `
${esc(f.detail)}
` : ""} +
`; + }).join("")} +
`).join("") + || `
No findings.
`); +} + +// Gallery card. SECURITY: the thumbnail iframe's sandbox is fixed here per kind — +// url tiles get "allow-scripts allow-same-origin allow-forms" (the framed app runs +// on its own origin, so same-origin grants it ITS origin, never the cockpit's), +// html snapshots get "" (the empty, maximally restrictive value: inert, opaque +// origin). The src/srcdoc is NOT embedded as an HTML-attribute string here; it is +// assigned as a DOM property after innerHTML in renderGallery (no attribute +// escaping). Every interpolated field passes through esc(). (gallery) +function glCard(it, i) { + const open = it.kind === "url" && it.src ? `open ↗` : ""; + const sandbox = it.kind === "url" ? `sandbox="allow-scripts allow-same-origin allow-forms"` : `sandbox=""`; + const cap = it.caption ? `${esc(it.caption)}` : ""; + const thumb = it.kind === "empty" + ? `
${esc(it.label)}
` + : ``; + return `
${esc(it.label)}${cap}${open}
${thumb}
`; +} + +// M/L thumbnails are a fixed 1200x780 iframe scaled to the fluid tile width. CSS cannot derive a +// scale ratio from a fluid container, so set transform: scale(width/1200) here (and on resize). +// We also set an explicit pixel height on the wrapper: a thumb whose height comes from +// aspect-ratio inside a 1fr grid column collapses the row (the track can't resolve the +// aspect-derived height), so a definite height is what makes the card take its full size. +function sizeGalleryThumbs() { + const grid = document.getElementById("gl-grid"); + if (!grid) return; + grid.querySelectorAll(".gl-thumb").forEach((thumb) => { + const w = thumb.clientWidth; + if (!w) return; + thumb.style.height = Math.round(w * 780 / 1200) + "px"; + const f = thumb.querySelector("iframe"); + if (f) f.style.transform = `scale(${w / 1200})`; + }); +} + +function renderGallery(v) { + const g = v.gallery || { title: null, size: "m", items: [] }; + glItems = g.items; + setText("gl-title", g.title || "Gallery"); + setText("gl-count", g.items.length ? `${g.items.length} items` : ""); + const grid = document.getElementById("gl-grid"); + if (!grid) return; + const size = glSizeOverride || g.size || "m"; + grid.className = `gsize-${size}`; + document.querySelectorAll(".gl-size").forEach((b) => b.classList.toggle("active", b.dataset.gsize === size)); + const order = []; + const byGroup = new Map(); + g.items.forEach((it, i) => { + const key = it.group || ""; + if (!byGroup.has(key)) { byGroup.set(key, []); order.push(key); } + byGroup.get(key).push({ it, i }); + }); + grid.innerHTML = order.map((key) => { + const head = key ? `
${esc(key)}
` : ""; + return head + byGroup.get(key).map(({ it, i }) => glCard(it, i)).join(""); + }).join("") || `
No items yet.
`; + // Assign each thumbnail source as a DOM property (no HTML-attribute escaping), then scale + // each iframe to the fluid tile width. + g.items.forEach((it, i) => { + const f = document.getElementById(`gl-thumb-${i}`); + if (!f) return; + if (it.kind === "url" && it.src && isGalleryUrl(it.src)) f.setAttribute("src", it.src); + else if (it.kind === "html") f.srcdoc = it.src || ""; + }); + sizeGalleryThumbs(); +} + +function openLightbox(i) { + const it = glItems[i]; + if (!it) return; + const lb = document.getElementById("gl-lightbox"); + const frame = document.getElementById("gl-lb-frame"); + const openLink = document.getElementById("gl-lb-open"); + setText("gl-lb-label", it.label); + if (it.kind === "url" && it.src && isGalleryUrl(it.src)) { + frame.removeAttribute("srcdoc"); frame.setAttribute("src", it.src); + if (openLink) { openLink.href = it.src; openLink.hidden = false; } + } else if (it.kind === "html") { + frame.removeAttribute("src"); frame.srcdoc = it.src || ""; + if (openLink) openLink.hidden = true; + } else { + frame.removeAttribute("src"); frame.srcdoc = ``; + if (openLink) openLink.hidden = true; + } + if (lb) lb.hidden = false; +} + +function closeLightbox() { + const lb = document.getElementById("gl-lightbox"); + const frame = document.getElementById("gl-lb-frame"); + if (frame) { frame.removeAttribute("src"); frame.removeAttribute("srcdoc"); } + if (lb) lb.hidden = true; +} + +function renderBoard(v) { + const b = v.board || { columns: [] }; + setText("board-target", b.target || "Backlog"); + const n = b.count || 0; + setText("board-count", n === 1 ? "1 item" : n + " items"); + const action = document.getElementById("board-action"); + if (action) { action.textContent = b.action || ""; action.hidden = !b.action; } + setHtml("board", (b.columns || []).map((c) => + `
+
${esc(c.name)}${c.items.length}
+ ${c.items.map((it) => + `
+
${esc(it.id)}
+ ${it.parentRef ? `
↳ under ${esc(it.parentRef)}
` : ""} +
${esc(it.title)}
+ ${(it.kind || it.tier) ? `
${it.kind ? `${esc(it.kind)}` : ""}${it.tier ? `${esc(it.tier)}` : ""}
` : ""} +
`).join("") || `
`} +
`).join("")); +} + +function renderDataProfile(v) { + const dp = v.dataProfile || { dataset: null, columns: [] }; + const ds = dp.dataset; + setText("dp-name", ds ? ds.name : "Dataset"); + const meta = ds ? [ds.rows != null ? `${ds.rows} rows` : null, ds.cols != null ? `${ds.cols} cols` : null, ds.source].filter(Boolean).join(" · ") : ""; + setText("dp-meta", meta); + const head = `ColumnTypeNull %DistinctStat`; + const body = (dp.columns || []).map((c) => + `${esc(c.name)}${esc(c.dtype)} + ${c.nullPct != null ? esc(String(c.nullPct)) + "%" : ""} + ${c.distinct != null ? esc(String(c.distinct)) : ""} + ${c.stat ? esc(c.stat) : ""} + ${c.quality ? `` : ""}`).join("") + || `No columns profiled yet.`; + setHtml("dp-table", head + `${body}`); +} + +function renderPromptlab(v) { + const p = v.promptlab || { name: null, round: 1, prompt: null, summary: { pass: 0, warn: 0, fail: 0, pending: 0, running: 0, total: 0 }, cases: [] }; + // Reconcile plOpen: drop any case id that no longer exists in this session's cases. + const currentIds = new Set((p.cases || []).map(c => c.id)); + for (const id of plOpen) if (!currentIds.has(id)) plOpen.delete(id); + setText("pl-name", `${p.name || "Prompt workbench"} · Round ${p.round}`); + const sm = p.summary; + const chip = (n, cls, label) => n > 0 ? `${n} ${label}` : ""; + setHtml("pl-summary", sm.total + ? chip(sm.pass, "pl-c-pass", "pass") + chip(sm.warn, "pl-c-warn", "warn") + chip(sm.fail, "pl-c-fail", "fail") + + chip(sm.running, "", "running") + chip(sm.pending, "", "pending") + : ""); + setHtml("pl-cases", (p.cases || []).map((c) => { + const preview = c.output ? esc(c.output.replace(/\s+/g, " ").slice(0, 120)) : "awaiting output…"; + const body = `
${c.output ? esc(c.output) : "No output yet."}
${c.note ? `
${esc(c.note)}
` : ""}
`; + return `
${esc(c.scenario)}${preview}${esc(c.verdict)}
${body}
`; + }).join("") || `
No cases yet.
`); + const pre = document.getElementById("pl-prompt"); + if (pre) pre.textContent = p.prompt || ""; +} + +function renderMemory(v) { + const m = v.memory || { title: null, counts: { recalled: 0, added: 0, updated: 0, total: 0 }, entries: [], handoffs: [] }; + setText("me-title", m.title || "Memory"); + const ct = m.counts; + const chip = (n, cls, label) => n > 0 ? `${n} ${label}` : ""; + setHtml("me-counts", ct.total + ? chip(ct.recalled, "me-c-recalled", "recalled") + chip(ct.added, "me-c-added", "added") + chip(ct.updated, "me-c-updated", "updated") + : ""); + // Reconcile expand state: drop ids no longer present so nothing carries across sessions. + const ids = new Set((m.entries || []).map((e) => e.id)); + for (const id of meOpen) if (!ids.has(id)) meOpen.delete(id); + // Group entries by category in first-seen order. + const order = []; + const byCat = new Map(); + (m.entries || []).forEach((e) => { + if (!byCat.has(e.category)) { byCat.set(e.category, []); order.push(e.category); } + byCat.get(e.category).push(e); + }); + setHtml("me-entries", order.map((cat) => { + const rows = byCat.get(cat).map((e) => { + const name = e.title ? esc(e.title) : esc(e.content.replace(/\s+/g, " ").slice(0, 60)); + const preview = esc(e.content.replace(/\s+/g, " ").slice(0, 120)); + return `
${name}${preview}${esc(e.tag)}
${esc(e.content)}
`; + }).join(""); + return `
${esc(cat)}
${rows}
`; + }).join("") || `
No memory yet.
`); + setHtml("me-handoffs", (m.handoffs || []).map((h) => + `
${esc(h.from)}
${esc(h.summary)}
${esc(h.action)}
`).join("") + || `
No handoffs.
`); +} + +function gwActiveFocus(v) { + // server narration wins when it changes; otherwise the local drill override holds. + if (v.flow.focus !== gwServerFocus) { gwServerFocus = v.flow.focus; gwFocusOverride = undefined; } + return gwFocusOverride !== undefined ? gwFocusOverride : v.flow.focus; +} + +function gwApplyCam() { + const world = document.getElementById("gw-world"); + if (world) world.style.transform = `translate(${gwCam.x}px, ${gwCam.y}px) scale(${gwCam.z})`; +} + +// Fit the laid-out graph's bounding box into the live canvas, centered with padding and +// zoom clamped to the same [0.3, 2] range the wheel uses. Called once per node-set/scope +// change (see gwFitSig) so a live-run status update preserves the user's pan/zoom, but a +// fresh pipeline or a drill-in re-frames. No-op when the canvas has no measured size yet +// (e.g. the happy-dom test harness), leaving the default camera untouched. +function gwFitToView(pos) { + const ids = Object.keys(pos); + const canvas = document.getElementById("gw-canvas"); + if (!ids.length || !canvas) return; + const r = canvas.getBoundingClientRect(); + if (!(r.width > 0 && r.height > 0)) return; + const NODE_W = 180, NODE_H = 76, PAD = 48; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const id of ids) { + const p = pos[id]; + minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); + maxX = Math.max(maxX, p.x + NODE_W); maxY = Math.max(maxY, p.y + NODE_H); + } + const gw = Math.max(1, maxX - minX), gh = Math.max(1, maxY - minY); + const z = Math.min(2, Math.max(0.3, Math.min((r.width - PAD * 2) / gw, (r.height - PAD * 2) / gh))); + gwCam.z = z; + gwCam.x = (r.width - gw * z) / 2 - minX * z; + gwCam.y = (r.height - gh * z) / 2 - minY * z; +} + +function renderFlow(v) { + const f = v.flow || { title: null, focus: null, nodes: [], edges: [] }; + const focus = gwActiveFocus(v); + setText("gw-title", focus ? `${f.title || "Flow"} · ${focus}` : (f.title || "Flow")); + const back = document.getElementById("gw-back"); + if (back) back.hidden = !focus; + const scope = focus || "orchestration"; + const nodes = f.nodes.filter((n) => n.scope === scope); + const edges = f.edges.filter((e) => e.scope === scope); + // legend + setHtml("gw-legend", ["workflow", "trigger", "guard", "agent", "output", "mcp"].map((k) => + `
${k}
`).join("")); + // layout + node cards + const pos = computeFlowLayout(nodes, edges); + const world = document.getElementById("gw-world"); + if (!world) return; + // SVG edge layer (built first so edges sit under the node cards) + const NODE_W = 180, NODE_H = 64; + const anchor = (id, side) => { + const p = pos[id] || { x: 0, y: 0 }; + return { x: p.x + (side === "out" ? NODE_W : 0), y: p.y + NODE_H / 2 }; + }; + // bounding box for the svg canvas size + let maxX = 0, maxY = 0; + for (const id in pos) { maxX = Math.max(maxX, pos[id].x + NODE_W); maxY = Math.max(maxY, pos[id].y + NODE_H); } + const edgesSvg = edges.map((e) => { + const a = anchor(e.from, "out"), b = anchor(e.to, "in"); + const k = Math.max(40, Math.abs(b.x - a.x) * 0.4); + const d = `M ${a.x} ${a.y} C ${a.x + k} ${a.y}, ${b.x - k} ${b.y}, ${b.x} ${b.y}`; + const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 - 6 }; + return `` + + (e.label ? `${esc(e.label)}` : ""); + }).join(""); + const svg = ` + + ${edgesSvg}`; + const nodesHtml = nodes.map((n) => { + const p = pos[n.id] || { x: 0, y: 0 }; + return `
+ +
${GW_GLYPH[n.kind] || "•"}${esc(n.label)}
+ ${n.sub ? `
${esc(n.sub)}
` : ""} + +
`; + }).join(""); + world.innerHTML = svg + nodesHtml; + // Re-fit the camera only when the active scope or node set changes, so live-run status + // updates (same nodes) keep the user's current pan/zoom. + const fitSig = scope + "::" + nodes.map((n) => n.id).join("|"); + if (fitSig !== gwFitSig) { gwFitSig = fitSig; gwFitToView(pos); } + gwApplyCam(); + // gwNodes is the full flow node set (every scope) so the inspector can resolve a + // selection even after a drill-in changes the visible scope to the selected + // workflow's (possibly empty) anatomy. gwPos holds only the active-scope layout, + // which is what the minimap renders. + gwPos = pos; gwNodes = f.nodes; + gwRenderInspector(); + gwRenderMinimap(); +} + +function gwRenderInspector() { + const insp = document.getElementById("gw-inspector"); + if (!insp) return; + const n = gwNodes.find((x) => x.id === gwSel); + if (!n) { insp.hidden = true; insp.innerHTML = ""; return; } + insp.hidden = false; + insp.innerHTML = `
${esc(n.label)}
+
kind
${esc(n.kind)}
+
status
${esc(n.status)}
+ ${n.sub ? `
detail
${esc(n.sub)}
` : ""}`; +} + +function gwRenderMinimap() { + const mm = document.getElementById("gw-minimap"); + const canvas = document.getElementById("gw-canvas"); + if (!mm || !canvas) return; + const ids = Object.keys(gwPos); + if (ids.length === 0) { mm.hidden = true; return; } + mm.hidden = false; + const NODE_W = 180, NODE_H = 64; + let maxX = 0, maxY = 0; + for (const id of ids) { maxX = Math.max(maxX, gwPos[id].x + NODE_W); maxY = Math.max(maxY, gwPos[id].y + NODE_H); } + const pad = 8, mw = 160 - pad * 2, mh = 110 - pad * 2; + const s = Math.min(mw / (maxX || 1), mh / (maxY || 1)); + const dots = ids.map((id) => ``).join(""); + // viewport rect: the canvas-visible world region under the current camera + const r = canvas.getBoundingClientRect(); + const vx = -gwCam.x / gwCam.z, vy = -gwCam.y / gwCam.z; + const vw = r.width / gwCam.z, vh = r.height / gwCam.z; + const view = ``; + mm.innerHTML = dots + view; + mm.dataset.scale = String(s); mm.dataset.pad = String(pad); +} + +function renderTeam(v) { + const t = v.team || { orchestrator: null, count: 0, columns: [] }; + setText("team-orch", t.orchestrator || "Orchestrator"); + const n = t.count || 0; + setText("team-count", n === 1 ? "1 agent" : n + " agents"); + setHtml("team-board", (t.columns || []).map((c) => + `
+
${esc(c.label)}${c.agents.length}
+ ${c.agents.map((a) => + `
+
${esc(a.name)}
+ ${a.role ? `
${esc(a.role)}
` : ""} + ${a.action ? `
${esc(a.action)}
` : ""} + ${esc(c.label)} +
+ + +
+
`).join("")} +
`).join("")); +} + +// Codebase map (CSS-3D). Build-once / update-in-place: rebuild the .cn node +// elements only when the node-id signature changes (a new map); on focus/touch +// re-renders only the state classes and the camera transform change, so the +// CSS transition glides the camera instead of snapping. (codemap) +function renderCodemap(v) { + const cm = v.codemap || { nodes: [], focus: null, touches: {} }; + const world = document.getElementById("cm-world"); + if (!world) return; + const sig = cm.nodes.map((n) => n.id).join("|"); + if (sig !== cmSig) { + // rebuild once per node set + cmSig = sig; + const groups = []; + cm.nodes.forEach((n) => { if (!groups.includes(n.group)) groups.push(n.group); }); + const perRow = Math.max(1, Math.ceil(Math.sqrt(groups.length))); + const counters = {}; + world.innerHTML = cm.nodes.map((n) => { + const gi = groups.indexOf(n.group); + const k = (counters[n.group] = (counters[n.group] || 0) + 1) - 1; + const cgx = gi % perRow, cgz = Math.floor(gi / perRow); + const clusterX = (cgx - (perRow - 1) / 2) * 430; + const clusterZ = cgz * 360; + const col = k % 2, row = Math.floor(k / 2); + const tx = Math.round(clusterX + (col - 0.5) * 150); + const ty = Math.round(row * 78 - 30); + const tz = Math.round(clusterZ); + const name = n.path.split("/").pop(); + return `
+
${esc(name)}
${esc(n.group)}
`; + }).join(""); + } + // update states + camera every time (no rebuild) + const cards = world.querySelectorAll(".cn"); + let fx = 0, fy = 0, fz = 0, haveFocus = false; + cards.forEach((el) => { + const id = el.dataset.node; + el.classList.toggle("focused", id === cm.focus); + const t = cm.touches[id]; + el.classList.toggle("read", t === "read"); + el.classList.toggle("edited", t === "edit"); + if (id === cm.focus) { + fx = parseFloat(el.style.getPropertyValue("--tx")) || 0; + fy = parseFloat(el.style.getPropertyValue("--ty")) || 0; + fz = parseFloat(el.style.getPropertyValue("--tz")) || 0; + haveFocus = true; + } + }); + world.style.transform = haveFocus + ? `translate3d(${-fx}px, ${-fy}px, ${-fz + 150}px)` + : `translate3d(0,0,-260px)`; +} + +function renderInterview(v) { + setText("iv-doctype", v.docType ? `Interview: ${v.docType}` : "Interview"); + const steps = document.getElementById("iv-steps"); + if (steps) { + const ist = v.interviewSteps; + if (ist && ist.steps && ist.steps.length) { + steps.hidden = false; + const lead = ist.label ? `${esc(ist.label)}` : ""; + steps.innerHTML = lead + ist.steps.map((st) => { + const prog = st.progress; + const extra = prog + ? `${esc(String(prog.done))}/${esc(String(prog.total))}` + : ""; + return `${st.status === "done" ? "✓" : ""}${esc(st.name)}${extra}`; + }).join(""); + } else { steps.hidden = true; steps.innerHTML = ""; } + } + const doc = document.getElementById("iv-doc"); + if (doc) doc.srcdoc = v.screen?.html ?? ""; +} + +// Pure layered layout for the flow canvas: longest-path layering on the DAG formed by +// dropping back edges (so a feedback handoff such as needs-revision -> implement does not +// change forward layering or loop), first-seen order within a layer. Returns world px. +function computeFlowLayout(nodes, edges) { + const COL_W = 240, ROW_H = 120; + const ids = nodes.map((n) => n.id); + const idx = new Map(ids.map((id, i) => [id, i])); + const idSet = new Set(ids); + const adj = new Map(ids.map((id) => [id, []])); + for (const e of edges) if (idSet.has(e.from) && idSet.has(e.to) && e.from !== e.to) adj.get(e.from).push(e.to); + // 1. classify back edges via DFS gray-coloring + const color = new Map(); // 1 = on stack, 2 = done + const back = new Set(); + const stack = []; + for (const root of ids) { + if (color.get(root)) continue; + stack.push([root, 0]); + while (stack.length) { + const frame = stack[stack.length - 1]; + const [u, i] = frame; + if (i === 0) color.set(u, 1); + const kids = adj.get(u); + if (i < kids.length) { + frame[1]++; + const v = kids[i]; + const c = color.get(v); + if (c === 1) back.add(u + ">" + v); + else if (!c) stack.push([v, 0]); + } else { + color.set(u, 2); + stack.pop(); + } + } + } + // 2. DAG (non-back edges) + indegree + const dag = new Map(ids.map((id) => [id, []])); + const indeg = new Map(ids.map((id) => [id, 0])); + for (const u of ids) for (const v of adj.get(u)) { + if (back.has(u + ">" + v)) continue; + dag.get(u).push(v); indeg.set(v, indeg.get(v) + 1); + } + // 3. Kahn topo + longest-path layer + const layer = new Map(ids.map((id) => [id, 0])); + const din = new Map(indeg); + let q = ids.filter((id) => din.get(id) === 0).sort((a, b) => idx.get(a) - idx.get(b)); + while (q.length) { + const u = q.shift(); + for (const v of dag.get(u)) { + if (layer.get(u) + 1 > layer.get(v)) layer.set(v, layer.get(u) + 1); + din.set(v, din.get(v) - 1); + if (din.get(v) === 0) q.push(v); + } + } + // 4. position: column = layer, row = first-seen order within layer + const byLayer = new Map(); + for (const id of ids) { + const L = layer.get(id); + if (!byLayer.has(L)) byLayer.set(L, []); + byLayer.get(L).push(id); + } + const pos = {}; + for (const [L, members] of byLayer) { + members.sort((a, b) => idx.get(a) - idx.get(b)); + members.forEach((id, i) => { pos[id] = { x: L * COL_W, y: i * ROW_H }; }); + } + return pos; +} + +connect(); + +if (typeof window !== "undefined") window.render = render; +if (typeof window !== "undefined") window.computeFlowLayout = computeFlowLayout; diff --git a/rpi-cockpit/public/index.html b/rpi-cockpit/public/index.html new file mode 100644 index 000000000..62d505e61 --- /dev/null +++ b/rpi-cockpit/public/index.html @@ -0,0 +1,742 @@ + + + + + +HVE Cockpit + + + + +
+ +
+
HVE Cockpit
+
· session
+
+ connecting… + + + +
+ + + + + +
+ + +
+ + +
+ + +
+
+

HVE Cockpit

+
+ +
Live subagents
+
+
+
+ +
Validation gate
+
+ + +
+ + +
+ + + + + + + + + + +
+ +
+
+ + + + + diff --git a/rpi-cockpit/src/bridge.ts b/rpi-cockpit/src/bridge.ts new file mode 100644 index 000000000..ad398275a --- /dev/null +++ b/rpi-cockpit/src/bridge.ts @@ -0,0 +1,146 @@ +// rpi-cockpit/src/bridge.ts +import { EventEmitter } from "node:events"; +import { initialState, applyBeat, enqueueDirective as reduceEnqueue, drainDirectives as reduceDrain, setView, startLaunch, setNavigatorOpen, addDecision, answerDecision, reviseDecision, setHostElicits as reduceSetHostElicits, type SessionState } from "./state.js"; +import type { Beat, OptionItem, InboundDirective, Directive } from "./events.js"; +import { WORKFLOWS } from "./catalog.js"; + +export class Bridge extends EventEmitter { + state: SessionState = initialState(); + private pending = new Map void>(); + private seq = 0; + + emitBeat(beat: Beat): void { + this.state = applyBeat(this.state, beat, Date.now()); + this.emit("state", this.state); + } + + enqueueDirective(directive: InboundDirective): void { + const stamped = { ...directive, id: `s${++this.seq}` } as Directive; + this.state = reduceEnqueue(this.state, stamped, Date.now()); + this.emit("state", this.state); + // Granular, additive: lets a file sink durably record the steering directive + // for hosts that read it off disk rather than via the in-process MCP drain. + this.emit("directive", stamped); + } + + requestLaunch(workflowId: string): void { + const wf = WORKFLOWS.find((w) => w.id === workflowId); + if (!wf) return; + this.state = startLaunch(this.state, wf.id); + // Reuse the directive channel: the agent drains this via check_directives + // and performs the launch. The cockpit never starts the agent itself. + this.enqueueDirective({ kind: "approach", value: wf.id, label: wf.intent }); + } + + // Intervention is intent only: enqueue a directive note the orchestrator drains + // via check_directives and acts on. The cockpit never pauses, swaps, or spawns an + // agent itself — it has no handle on the running agents. Reuse enqueueDirective so + // the note rides the same talk-back channel as steer (stamped, logged, file-sunk). + intervene(action: "pause" | "swap" | "spawn", agentId?: string): void { + const text = action === "spawn" + ? "intervene: spawn a new agent" + : `intervene: ${action} agent ${agentId ?? ""}`.trim(); + this.enqueueDirective({ kind: "note", text }); + } + + navigate(screen: "home" | "loop"): void { + this.state = setView(this.state, screen); + this.emit("state", this.state); + } + + openNavigator(): void { + this.state = setNavigatorOpen(this.state, true); + this.emit("state", this.state); + } + + closeNavigator(): void { + this.state = setNavigatorOpen(this.state, false); + this.emit("state", this.state); + } + + drainDirectives(): Directive[] { + const { state, drained } = reduceDrain(this.state, Date.now()); + if (drained.length > 0) { + this.state = state; + this.emit("state", this.state); + } + return drained; + } + + offerApproaches(label: string, options: OptionItem[]): void { + this.emitBeat({ type: "approaches.offer", label, options }); + } + + showScreen(html: string, title?: string): void { + this.emitBeat({ type: "screen.show", html, title }); + } + + clearScreen(): void { + this.emitBeat({ type: "screen.clear" }); + } + + presentOptions(prompt: string, options: OptionItem[], timeoutMs = 0, id?: string): Promise { + const did = id ?? `d${++this.seq}`; + this.state = addDecision(this.state, { id: did, prompt, kind: "choice", options }); + this.emit("state", this.state); + return new Promise((resolve) => { + this.pending.set(did, resolve); + if (timeoutMs > 0) setTimeout(() => { + if (this.pending.has(did)) { + // Log the auto-resolve so a timeout fallback is distinguishable from a + // real user pick (the durable decisions.jsonl records only the choiceId). (B3) + const fallback = options.find((o) => o.recommended)?.id ?? options[0]?.id; + if (fallback !== undefined) { + this.state = { ...this.state, log: [...this.state.log, { t: Date.now(), kind: "decision.timeout", detail: `auto-resolved to ${fallback}` }] }; + this.emit("state", this.state); + this.resolveDecision(did, fallback); + } + } + }, timeoutMs); + }); + } + + resolveDecision(id: string, choiceId: string): void { + const resolve = this.pending.get(id); + if (!resolve) return; + this.pending.delete(id); + const prompt = this.state.decisions.find((d) => d.id === id)?.prompt; + this.state = answerDecision(this.state, id, choiceId); + this.emit("state", this.state); + resolve(choiceId); + // Additive: only emitted on a real resolution (unknown ids returned above). + this.emit("decision", prompt === undefined ? { id, choiceId } : { id, choiceId, prompt }); + } + + askQuestion(prompt: string, timeoutMs = 0, id?: string): Promise { + const qid = id ?? `q${++this.seq}`; + this.state = addDecision(this.state, { id: qid, prompt, kind: "text" }); + this.emit("state", this.state); + return new Promise((resolve) => { + this.pending.set(qid, resolve); + if (timeoutMs > 0) setTimeout(() => { if (this.pending.has(qid)) this.resolveQuestion(qid, ""); }, timeoutMs); + }); + } + + resolveQuestion(id: string, text: string): void { + const resolve = this.pending.get(id); + if (!resolve) return; + this.pending.delete(id); + this.state = answerDecision(this.state, id, text); + this.emit("state", this.state); + resolve(text); + } + + revise(id: string): void { + const entry = this.state.decisions.find((d) => d.id === id); + if (!entry) return; + this.state = reviseDecision(this.state, id); + this.emit("state", this.state); + this.enqueueDirective({ kind: "note", text: `revise decision "${entry.prompt}" (id ${id}): re-ask it and reconsider what follows` }); + } + + setHostElicits(v: boolean): void { + this.state = reduceSetHostElicits(this.state, v); + this.emit("state", this.state); + } +} diff --git a/rpi-cockpit/src/catalog.ts b/rpi-cockpit/src/catalog.ts new file mode 100644 index 000000000..dbd0300e9 --- /dev/null +++ b/rpi-cockpit/src/catalog.ts @@ -0,0 +1,58 @@ +// src/catalog.ts +// The static capability catalog: the workflows the Navigator home offers as +// tiles. `intent` is the directive text sent to the host agent when the user +// clicks a tile; the agent does the actual launch. Names use goal language, +// mapping onto the workflow archetypes in docs/representation-map.md. + +export interface Workflow { + id: string; + name: string; + hint: string; + description: string; + intent: string; +} + +export const WORKFLOWS: Workflow[] = [ + { + id: "build", + name: "Build code", + hint: "Research, plan, implement", + description: "Research, plan, and implement a change end to end, pausing at each decision for your call.", + intent: "Launch the Build code workflow: run the RPI build loop (research, plan, implement, review, discover) for the task I describe next.", + }, + { + id: "review", + name: "Review code", + hint: "Findings by severity", + description: "Point it at a branch or pull request for severity-ranked findings: bugs, security, accessibility, with file links.", + intent: "Launch the Review code workflow: run a code review and report findings grouped by severity with file and line links.", + }, + { + id: "plan", + name: "Plan and backlog", + hint: "Triage and sprint", + description: "Triage and shape work in GitHub, Azure DevOps, or Jira: discover, plan a sprint, and execute.", + intent: "Launch the Plan and backlog workflow: triage and shape backlog work (discover, sprint plan, execute).", + }, + { + id: "docs", + name: "Write docs and specs", + hint: "Guided interview", + description: "A guided interview that builds a product brief, a decision record, or a security plan, one question at a time.", + intent: "Launch the Write docs and specs workflow: run a guided document interview (product brief, decision record, or security plan).", + }, + { + id: "data", + name: "Analyze data", + hint: "Notebooks and dashboards", + description: "Turn a question and a dataset into a notebook, a dashboard, or a spec, previewed as it builds.", + intent: "Launch the Analyze data workflow: turn a question and a dataset into a notebook, a dashboard, or a spec.", + }, + { + id: "coach", + name: "Coach and learn", + hint: "Methods and practices", + description: "Work through a method with a coach: design thinking, agile practices, or experiment design, at your pace.", + intent: "Launch the Coach and learn workflow: work through a method or curriculum with a coach.", + }, +]; diff --git a/rpi-cockpit/src/elicit.ts b/rpi-cockpit/src/elicit.ts new file mode 100644 index 000000000..56ff9a5f8 --- /dev/null +++ b/rpi-cockpit/src/elicit.ts @@ -0,0 +1,185 @@ +// src/elicit.ts +// The decision/question primitive's elicitation path. Pure mappers turn the +// cockpit's OptionItem list into an MCP elicitation form and turn the client's +// ElicitResult back into an option id. The orchestrator (Task 2) races the +// in-pane card against the native elicitation card. +import type { OptionItem } from "./events.js"; +import type { ElicitResult } from "@modelcontextprotocol/sdk/types.js"; +import type { Bridge } from "./bridge.js"; +import { WORKFLOWS } from "./catalog.js"; + +// A decision must not block the agent forever: fall back to the recommended +// option after a finite timeout. Configurable via env (default 30 min). +const DEFAULT_DECISION_TIMEOUT_MS = 1_800_000; +export function decisionTimeoutMs(): number { + const raw = Number(process.env.RPI_COCKPIT_DECISION_TIMEOUT_MS); + return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_DECISION_TIMEOUT_MS; +} + +// A free-text question (the interview) gets its OWN timeout, defaulting to 0 = +// no auto-resolve. A silent fallback to "" after 30 min is indistinguishable from +// a deliberate empty answer, so the interview blocks until the user actually +// answers. Override via RPI_COCKPIT_QUESTION_TIMEOUT_MS (bridge.askQuestion treats +// timeoutMs<=0 as "no timeout"). (M3/M4) +export function questionTimeoutMs(): number { + const raw = Number(process.env.RPI_COCKPIT_QUESTION_TIMEOUT_MS); + return Number.isFinite(raw) && raw > 0 ? raw : 0; +} + +export interface ElicitFormParams { + message: string; + requestedSchema: { type: "object"; properties: Record; required?: string[] }; +} + +// Form mode with a single required string property whose oneOf carries the +// options as const/title pairs (the canonical SDK shape for a labelled choice), +// defaulting to the recommended option. +export function optionsToElicitSchema(prompt: string, options: OptionItem[]): ElicitFormParams { + const fallback = options.find((o) => o.recommended) ?? options[0]; + if (!fallback) throw new Error("optionsToElicitSchema: options must not be empty"); + return { + message: prompt, + requestedSchema: { + type: "object", + properties: { + choice: { + type: "string", + title: "Choose an option", + oneOf: options.map((o) => ({ const: o.id, title: o.title })), + default: fallback.id, + }, + }, + required: ["choice"], + }, + }; +} + +// Only an accepted result with a known option id counts as a choice. Decline, +// cancel, missing content, and unknown ids all return null (no decision). +export function elicitResultToChoice(result: ElicitResult, options: OptionItem[]): string | null { + if (result.action !== "accept" || !result.content) return null; + const choice = result.content.choice; + if (typeof choice !== "string") return null; + return options.some((o) => o.id === choice) ? choice : null; +} + +// Minimal server surface the orchestrator needs; the real McpServer's underlying +// Server satisfies it (adapted in mcp.ts). +export interface ElicitCapableServer { + getClientCapabilities(): { elicitation?: unknown } | undefined; + elicitInput(params: ElicitFormParams, options?: { signal?: AbortSignal }): Promise; +} + +// The generic first-answer-wins race shared by the decision and the question. +// Shows the pane (panePromise) always; if the host supports elicitation, also +// sends a native card and races them. The loser is dismissed: the pane win +// aborts the elicitation; the elicitation win resolves the pane via resolvePane. +// A null-mapped elicitation result (decline/cancel/invalid) is ignored. +async function raceElicitation( + server: ElicitCapableServer, + panePromise: Promise, + paneId: string | null, + schema: ElicitFormParams, + mapResult: (r: ElicitResult) => T | null, + resolvePane: (id: string, value: T) => void, +): Promise { + const canElicit = server.getClientCapabilities()?.elicitation !== undefined; + if (!canElicit) return panePromise; + const ac = new AbortController(); + return await new Promise((resolve) => { + let settled = false; + void panePromise.then((v) => { + if (settled) return; + settled = true; + ac.abort(); + resolve(v); + }); + void server + .elicitInput(schema, { signal: ac.signal }) + .then((result) => { + if (settled) return; + const mapped = mapResult(result); + if (mapped === null) return; + settled = true; + if (paneId) resolvePane(paneId, mapped); + resolve(mapped); + }) + .catch(() => { /* aborted or transport error */ }); + }); +} + +export async function presentOptionsWithElicitation( + server: ElicitCapableServer, + bridge: Bridge, + prompt: string, + options: OptionItem[], + timeoutMs: number, + id?: string, +): Promise { + const webPromise = bridge.presentOptions(prompt, options, timeoutMs, id); + const paneId = id ?? bridge.state.decisions.at(-1)?.id ?? null; + return raceElicitation( + server, + webPromise, + paneId, + optionsToElicitSchema(prompt, options), + (r) => elicitResultToChoice(r, options), + (pid, choice) => bridge.resolveDecision(pid, choice), + ); +} + +export function questionToElicitSchema(prompt: string): ElicitFormParams { + return { + message: prompt, + requestedSchema: { + type: "object", + properties: { answer: { type: "string", title: "Your answer" } }, + required: ["answer"], + }, + }; +} + +export function elicitResultToAnswer(result: ElicitResult): string | null { + if (result.action !== "accept" || !result.content) return null; + const answer = result.content.answer; + return typeof answer === "string" ? answer : null; +} + +export async function askQuestionWithElicitation( + server: ElicitCapableServer, + bridge: Bridge, + prompt: string, + timeoutMs: number, + id?: string, +): Promise { + const webPromise = bridge.askQuestion(prompt, timeoutMs, id); + const paneId = id ?? bridge.state.decisions.at(-1)?.id ?? null; + return raceElicitation( + server, + webPromise, + paneId, + questionToElicitSchema(prompt), + elicitResultToAnswer, + (pid, text) => bridge.resolveQuestion(pid, text), + ); +} + +// Native-elicitation-only launcher. The workflow launcher lives in the chat, not +// the pane: on capability it shows the native choice card and returns the chosen +// workflow's launch instruction; without capability it returns a text instruction +// for the agent to ask in chat. It does NOT touch bridge state (the agent launches +// the chosen workflow by narrating its beats). +export async function presentWorkflows(server: ElicitCapableServer): Promise { + const options = WORKFLOWS.map((w) => ({ id: w.id, title: w.name, detail: w.description })); + if (server.getClientCapabilities()?.elicitation === undefined) { + return "This host does not support inline choices. Ask the user which workflow to start: " + WORKFLOWS.map((w) => w.name).join(", ") + "."; + } + try { + const result = await server.elicitInput(optionsToElicitSchema("Start a workflow", options)); + const choice = elicitResultToChoice(result, options); + const wf = choice ? WORKFLOWS.find((w) => w.id === choice) : null; + return wf ? wf.intent : "The user did not pick a workflow."; + } catch { + return "The workflow choice was cancelled."; + } +} diff --git a/rpi-cockpit/src/events.ts b/rpi-cockpit/src/events.ts new file mode 100644 index 000000000..f28926fba --- /dev/null +++ b/rpi-cockpit/src/events.ts @@ -0,0 +1,106 @@ +// rpi-cockpit/src/events.ts +import { z } from "zod"; + +export const Phase = z.enum(["research", "plan", "implement", "review", "discover"]); +export type Phase = z.infer; + +export const ValidationStatus = z.enum(["ok", "running", "fail", "pending"]); +export type ValidationStatus = z.infer; + +export const AgentStatus = z.enum(["queued", "running", "blocked", "done", "failed"]); +export type AgentStatus = z.infer; + +export const CodeKind = z.enum(["file", "dir"]); +export type CodeKind = z.infer; + +export const TouchKind = z.enum(["read", "edit"]); +export type TouchKind = z.infer; + +export const OptionItem = z.object({ + id: z.string(), + title: z.string(), + detail: z.string().optional(), + recommended: z.boolean().optional(), +}); +export type OptionItem = z.infer; + +export const GalleryItem = z.object({ + id: z.string().optional(), + label: z.string(), + group: z.string().optional(), + url: z.string().optional(), + html: z.string().optional(), + caption: z.string().optional(), +}); +export type GalleryItem = z.infer; + +export const Severity = z.enum(["critical", "high", "medium", "low", "info"]); +export type Severity = z.infer; + +export const Finding = z.object({ + severity: Severity, + title: z.string(), + file: z.string().optional(), + line: z.number().int().optional(), + detail: z.string().optional(), +}); +export type Finding = z.infer; + +export const Beat = z.discriminatedUnion("type", [ + z.object({ type: z.literal("session.begin"), task: z.string(), host: z.string() }), + z.object({ type: z.literal("phase.enter"), phase: Phase }), + z.object({ type: z.literal("subagent.start"), name: z.string(), role: z.string().optional() }), + z.object({ type: z.literal("subagent.stop"), name: z.string(), result: z.string().optional() }), + z.object({ type: z.literal("artifact.update"), path: z.string(), summary: z.string().optional() }), + z.object({ type: z.literal("validate"), check: z.string(), status: ValidationStatus }), + z.object({ type: z.literal("approaches.offer"), label: z.string(), options: z.array(OptionItem).min(1) }), + z.object({ type: z.literal("screen.show"), html: z.string(), title: z.string().optional() }), + z.object({ type: z.literal("screen.clear") }), + z.object({ type: z.literal("review.start"), target: z.string() }), + z.object({ type: z.literal("finding.add"), severity: Severity, title: z.string(), file: z.string().optional(), line: z.number().int().optional(), detail: z.string().optional() }), + z.object({ type: z.literal("interview.start"), docType: z.string() }), + z.object({ type: z.literal("steps.set"), steps: z.array(z.string()).min(1), current: z.number().int(), label: z.string().optional(), progress: z.object({ done: z.number().int(), total: z.number().int() }).optional() }), + z.object({ type: z.literal("backlog.start"), target: z.string(), columns: z.array(z.string()).min(1) }), + z.object({ type: z.literal("item.add"), id: z.string(), title: z.string(), column: z.string(), kind: z.string().optional(), tier: z.string().optional(), parent: z.string().optional() }), + z.object({ type: z.literal("item.move"), id: z.string(), column: z.string() }), + z.object({ type: z.literal("backlog.action"), text: z.string().nullable() }), + z.object({ type: z.literal("profile.start"), name: z.string(), rows: z.number().int().optional(), columns: z.number().int().optional(), source: z.string().optional() }), + z.object({ type: z.literal("column.add"), name: z.string(), dtype: z.string(), nullPct: z.number().optional(), distinct: z.number().int().optional(), stat: z.string().optional(), quality: z.enum(["ok", "warn", "risk"]).optional() }), + z.object({ type: z.literal("context.set"), instructions: z.array(z.string()), skills: z.array(z.string()), collection: z.string().nullable() }), + z.object({ type: z.literal("appframe.set"), url: z.string().nullable() }), + z.object({ type: z.literal("team.start"), task: z.string(), orchestrator: z.string() }), + z.object({ type: z.literal("agent.add"), id: z.string(), name: z.string(), role: z.string().optional(), status: AgentStatus }), + z.object({ type: z.literal("agent.update"), id: z.string(), status: AgentStatus.optional(), action: z.string().nullable().optional() }), + z.object({ type: z.literal("agent.remove"), id: z.string() }), + z.object({ type: z.literal("codemap.set"), nodes: z.array(z.object({ id: z.string(), path: z.string(), kind: CodeKind, group: z.string().optional() })).max(60) }), + z.object({ type: z.literal("codemap.focus"), id: z.string() }), + z.object({ type: z.literal("codemap.touch"), id: z.string(), kind: TouchKind }), + z.object({ type: z.literal("gallery.open"), title: z.string(), size: z.enum(["s", "m", "l"]).optional(), items: z.array(GalleryItem) }), + z.object({ type: z.literal("gallery.add"), item: GalleryItem }), + z.object({ type: z.literal("gallery.clear") }), + z.object({ type: z.literal("promptlab.start"), name: z.string(), prompt: z.string().optional(), round: z.number().int().optional() }), + z.object({ type: z.literal("case.add"), id: z.string(), scenario: z.string(), output: z.string().optional(), verdict: z.enum(["pending", "running", "pass", "warn", "fail"]).optional(), note: z.string().optional() }), + z.object({ type: z.literal("memory.open"), title: z.string().optional() }), + z.object({ type: z.literal("memory.add"), id: z.string(), content: z.string(), category: z.string(), tag: z.enum(["recalled", "added", "updated"]).optional(), title: z.string().optional() }), + z.object({ type: z.literal("handoff.add"), id: z.string(), from: z.string(), summary: z.string(), action: z.enum(["stored", "merged", "recalled"]).optional() }), + z.object({ type: z.literal("flow.open"), title: z.string().optional() }), + z.object({ type: z.literal("flownode.add"), id: z.string(), kind: z.enum(["workflow", "trigger", "guard", "agent", "output", "mcp"]), label: z.string(), scope: z.string().optional(), sub: z.string().optional(), status: z.enum(["idle", "running", "passed", "failed", "skipped", "stale"]).optional() }), + z.object({ type: z.literal("flowedge.add"), id: z.string(), from: z.string(), to: z.string(), scope: z.string().optional(), label: z.string().optional(), kind: z.enum(["label", "event", "output", "step"]).optional(), status: z.enum(["idle", "active"]).optional() }), + z.object({ type: z.literal("flow.focus"), workflow: z.string().nullable().optional() }), +]); +export type Beat = z.infer; + +export const InboundDirective = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("note"), text: z.string().min(1) }), + z.object({ kind: z.literal("approach"), value: z.string().min(1), label: z.string() }), +]); +export type InboundDirective = z.infer; + +export const Directive = z.discriminatedUnion("kind", [ + z.object({ id: z.string(), kind: z.literal("note"), text: z.string().min(1) }), + z.object({ id: z.string(), kind: z.literal("approach"), value: z.string().min(1), label: z.string() }), +]); +export type Directive = z.infer; + +export const SteerMsg = z.object({ type: z.literal("steer"), directive: InboundDirective }); +export type SteerMsg = z.infer; diff --git a/rpi-cockpit/src/handlers.ts b/rpi-cockpit/src/handlers.ts new file mode 100644 index 000000000..c86a4e2b9 --- /dev/null +++ b/rpi-cockpit/src/handlers.ts @@ -0,0 +1,182 @@ +// rpi-cockpit/src/handlers.ts +import type { Bridge } from "./bridge.js"; +import type { AgentStatus, CodeKind, OptionItem, Phase, Severity, TouchKind, ValidationStatus } from "./events.js"; +import type { PromptVerdict, MemoryTag, HandoffAction, FlowNodeKind, FlowStatus, FlowEdgeKind, FlowEdgeStatus } from "./state.js"; +import { isLoopbackHttpUrl, isGalleryUrl } from "./url.js"; + +export const handlers = { + session_begin: (b: Bridge, a: { task: string; host: string }) => { + b.emitBeat({ type: "session.begin", task: a.task, host: a.host }); + return "session started"; + }, + phase_enter: (b: Bridge, a: { phase: Phase }) => { + b.emitBeat({ type: "phase.enter", phase: a.phase }); + return `entered ${a.phase}`; + }, + subagent_start: (b: Bridge, a: { name: string; role?: string }) => { + b.emitBeat({ type: "subagent.start", name: a.name, role: a.role }); + return `${a.name} started`; + }, + subagent_stop: (b: Bridge, a: { name: string; result?: string }) => { + b.emitBeat({ type: "subagent.stop", name: a.name, result: a.result }); + return `${a.name} stopped`; + }, + artifact_update: (b: Bridge, a: { path: string; summary?: string }) => { + b.emitBeat({ type: "artifact.update", path: a.path, summary: a.summary }); + return `${a.path} updated`; + }, + validate: (b: Bridge, a: { check: string; status: ValidationStatus }) => { + b.emitBeat({ type: "validate", check: a.check, status: a.status }); + return `${a.check}=${a.status}`; + }, + review_start: (b: Bridge, a: { target: string }) => { + b.emitBeat({ type: "review.start", target: a.target }); + return `review started: ${a.target}`; + }, + add_finding: (b: Bridge, a: { severity: Severity; title: string; file?: string; line?: number; detail?: string }) => { + b.emitBeat({ type: "finding.add", severity: a.severity, title: a.title, file: a.file, line: a.line, detail: a.detail }); + return `finding added: ${a.severity}`; + }, + interview_start: (b: Bridge, a: { docType: string }) => { + b.emitBeat({ type: "interview.start", docType: a.docType }); + return `interview started: ${a.docType}`; + }, + set_steps: (b: Bridge, a: { steps: string[]; current: number; label?: string; progress?: { done: number; total: number } }) => { + b.emitBeat({ type: "steps.set", steps: a.steps, current: a.current, label: a.label, progress: a.progress }); + return `steps set: ${a.steps.length}`; + }, + backlog_start: (b: Bridge, a: { target: string; columns: string[] }) => { + b.emitBeat({ type: "backlog.start", target: a.target, columns: a.columns }); + return `backlog started: ${a.target}`; + }, + add_item: (b: Bridge, a: { id: string; title: string; column: string; kind?: string; tier?: string; parent?: string }) => { + b.emitBeat({ type: "item.add", id: a.id, title: a.title, column: a.column, kind: a.kind, tier: a.tier, parent: a.parent }); + return `item added: ${a.id}`; + }, + move_item: (b: Bridge, a: { id: string; column: string }) => { + b.emitBeat({ type: "item.move", id: a.id, column: a.column }); + return `item moved: ${a.id}`; + }, + set_backlog_action: (b: Bridge, a: { text: string | null }) => { + b.emitBeat({ type: "backlog.action", text: a.text }); + return a.text ? `action: ${a.text}` : "action cleared"; + }, + dataset_profile: (b: Bridge, a: { name: string; rows?: number; columns?: number; source?: string }) => { + b.emitBeat({ type: "profile.start", name: a.name, rows: a.rows, columns: a.columns, source: a.source }); + return `profile started: ${a.name}`; + }, + add_column: (b: Bridge, a: { name: string; dtype: string; nullPct?: number; distinct?: number; stat?: string; quality?: "ok" | "warn" | "risk" }) => { + b.emitBeat({ type: "column.add", name: a.name, dtype: a.dtype, nullPct: a.nullPct, distinct: a.distinct, stat: a.stat, quality: a.quality }); + return `column added: ${a.name}`; + }, + team_start: (b: Bridge, a: { task: string; orchestrator: string }) => { + b.emitBeat({ type: "team.start", task: a.task, orchestrator: a.orchestrator }); + return `team started: ${a.orchestrator}`; + }, + add_agent: (b: Bridge, a: { id: string; name: string; role?: string; status: AgentStatus }) => { + b.emitBeat({ type: "agent.add", id: a.id, name: a.name, role: a.role, status: a.status }); + return `agent added: ${a.name}`; + }, + update_agent: (b: Bridge, a: { id: string; status?: AgentStatus; action?: string | null }) => { + b.emitBeat({ type: "agent.update", id: a.id, status: a.status, action: a.action }); + return `agent updated: ${a.id}`; + }, + remove_agent: (b: Bridge, a: { id: string }) => { + b.emitBeat({ type: "agent.remove", id: a.id }); + return `agent removed: ${a.id}`; + }, + codemap_set: (b: Bridge, a: { nodes: { id: string; path: string; kind: CodeKind; group?: string }[] }) => { + b.emitBeat({ type: "codemap.set", nodes: a.nodes }); + return `codemap set: ${a.nodes.length} nodes`; + }, + codemap_focus: (b: Bridge, a: { id: string }) => { + b.emitBeat({ type: "codemap.focus", id: a.id }); + return `focus ${a.id}`; + }, + codemap_touch: (b: Bridge, a: { id: string; kind: TouchKind }) => { + b.emitBeat({ type: "codemap.touch", id: a.id, kind: a.kind }); + return `${a.kind} ${a.id}`; + }, + set_context: (b: Bridge, a: { instructions?: string[]; skills?: string[]; collection?: string | null }) => { + b.emitBeat({ type: "context.set", instructions: a.instructions ?? [], skills: a.skills ?? [], collection: a.collection ?? null }); + return "context updated"; + }, + set_app_frame: (b: Bridge, a: { url: string | null }) => { + if (a.url !== null && !isLoopbackHttpUrl(a.url)) { + return "rejected: the app frame URL must be a loopback http(s) URL (localhost, 127.0.0.1, or [::1])"; + } + b.emitBeat({ type: "appframe.set", url: a.url }); + return a.url ? `app frame set: ${a.url}` : "app frame cleared"; + }, + offer_approaches: (b: Bridge, a: { label: string; options: OptionItem[] }) => { + b.offerApproaches(a.label, a.options); + return `offered ${a.options.length} approaches`; + }, + check_directives: (b: Bridge) => { + const drained = b.drainDirectives(); + if (drained.length === 0) return "no pending directives"; + return drained.map((d) => (d.kind === "note" ? `note: ${d.text}` : `approach: ${d.label}`)).join("\n"); + }, + show_screen: (b: Bridge, a: { html: string; title?: string }) => { + b.showScreen(a.html, a.title); + return a.title ? `screen shown: ${a.title}` : "screen shown"; + }, + clear_screen: (b: Bridge) => { + b.clearScreen(); + return "screen cleared"; + }, + open_navigator: (b: Bridge) => { + b.openNavigator(); + return "navigator opened"; + }, + gallery_open: (b: Bridge, a: { title: string; size?: "s" | "m" | "l"; items: { id?: string; label: string; group?: string; url?: string; html?: string; caption?: string }[] }) => { + for (const it of a.items) if (it.url && !isGalleryUrl(it.url)) throw new Error(`gallery url must be loopback http(s) or external https: ${it.url}`); + b.emitBeat({ type: "gallery.open", title: a.title, size: a.size, items: a.items }); + return `gallery opened: ${a.title} (${a.items.length})`; + }, + gallery_add: (b: Bridge, a: { item: { id?: string; label: string; group?: string; url?: string; html?: string; caption?: string } }) => { + if (a.item.url && !isGalleryUrl(a.item.url)) throw new Error(`gallery url must be loopback http(s) or external https: ${a.item.url}`); + b.emitBeat({ type: "gallery.add", item: a.item }); + return `gallery item: ${a.item.label}`; + }, + gallery_clear: (b: Bridge) => { + b.emitBeat({ type: "gallery.clear" }); + return "gallery cleared"; + }, + promptlab_start: (b: Bridge, a: { name: string; prompt?: string; round?: number }) => { + b.emitBeat({ type: "promptlab.start", name: a.name, prompt: a.prompt, round: a.round }); + return `promptlab started: ${a.name}`; + }, + add_case: (b: Bridge, a: { id: string; scenario: string; output?: string; verdict?: PromptVerdict; note?: string }) => { + b.emitBeat({ type: "case.add", id: a.id, scenario: a.scenario, output: a.output, verdict: a.verdict, note: a.note }); + return `case ${a.id}: ${a.verdict ?? "pending"}`; + }, + memory_open: (b: Bridge, a: { title?: string }) => { + b.emitBeat({ type: "memory.open", title: a.title }); + return `memory view opened${a.title ? `: ${a.title}` : ""}`; + }, + add_memory: (b: Bridge, a: { id: string; content: string; category: string; tag?: MemoryTag; title?: string }) => { + b.emitBeat({ type: "memory.add", id: a.id, content: a.content, category: a.category, tag: a.tag, title: a.title }); + return `memory ${a.id}: ${a.tag ?? "recalled"}`; + }, + add_handoff: (b: Bridge, a: { id: string; from: string; summary: string; action?: HandoffAction }) => { + b.emitBeat({ type: "handoff.add", id: a.id, from: a.from, summary: a.summary, action: a.action }); + return `handoff ${a.id} from ${a.from}: ${a.action ?? "stored"}`; + }, + flow_open: (b: Bridge, a: { title?: string }) => { + b.emitBeat({ type: "flow.open", title: a.title }); + return `flow canvas opened${a.title ? `: ${a.title}` : ""}`; + }, + add_flow_node: (b: Bridge, a: { id: string; kind: FlowNodeKind; label: string; scope?: string; sub?: string; status?: FlowStatus }) => { + b.emitBeat({ type: "flownode.add", id: a.id, kind: a.kind, label: a.label, scope: a.scope, sub: a.sub, status: a.status }); + return `flow node ${a.id} (${a.kind})`; + }, + add_flow_edge: (b: Bridge, a: { id: string; from: string; to: string; scope?: string; label?: string; kind?: FlowEdgeKind; status?: FlowEdgeStatus }) => { + b.emitBeat({ type: "flowedge.add", id: a.id, from: a.from, to: a.to, scope: a.scope, label: a.label, kind: a.kind, status: a.status }); + return `flow edge ${a.from} -> ${a.to}`; + }, + flow_focus: (b: Bridge, a: { workflow?: string | null }) => { + b.emitBeat({ type: "flow.focus", workflow: a.workflow ?? null }); + return `flow focus: ${a.workflow ?? "(orchestration)"}`; + }, +}; diff --git a/rpi-cockpit/src/inbound.ts b/rpi-cockpit/src/inbound.ts new file mode 100644 index 000000000..63d5ea90f --- /dev/null +++ b/rpi-cockpit/src/inbound.ts @@ -0,0 +1,115 @@ +// rpi-cockpit/src/inbound.ts +// Single source of truth for inbound WebSocket frame validation and dispatch. +// Both the in-process WS handler (server.ts) and the producer's inbox tailer +// (live.ts) parse with parseInbound and apply with applyInbound, so a user +// intent validates and drives the bridge identically across the process boundary. +import type { Bridge } from "./bridge.js"; +import { SteerMsg, type InboundDirective } from "./events.js"; + +export type InboundFrame = + | { type: "decide"; id: string; choiceId: string } + | { type: "steer"; directive: InboundDirective } + | { type: "launch"; workflowId: string } + | { type: "navigate"; screen: "home" | "loop" } + | { type: "answer"; id: string; text: string } + | { type: "navigator"; open: boolean } + | { type: "intervene"; action: "pause" | "swap" | "spawn"; agentId?: string } + | { type: "revise"; id: string } + | { type: "open"; file: string; line?: number }; + +// Mirror the EXACT validation the WS handler used. Return null on anything +// malformed or unrecognized so the caller can ignore it without crashing. +export function parseInbound(msg: unknown): InboundFrame | null { + if (!msg || typeof msg !== "object") return null; + const type = (msg as { type?: unknown }).type; + if (type === "decide") { + const m = msg as { id?: unknown; choiceId?: unknown }; + if (typeof m.id === "string" && typeof m.choiceId === "string") { + return { type: "decide", id: m.id, choiceId: m.choiceId }; + } + return null; + } + if (type === "steer") { + const parsed = SteerMsg.safeParse(msg); + if (parsed.success) return { type: "steer", directive: parsed.data.directive }; + return null; + } + if (type === "launch") { + const m = msg as { workflowId?: unknown }; + if (typeof m.workflowId === "string") return { type: "launch", workflowId: m.workflowId }; + return null; + } + if (type === "navigate") { + const m = msg as { screen?: unknown }; + if (m.screen === "home" || m.screen === "loop") return { type: "navigate", screen: m.screen }; + return null; + } + if (type === "answer") { + const m = msg as { id?: unknown; text?: unknown }; + if (typeof m.id === "string" && typeof m.text === "string") { + return { type: "answer", id: m.id, text: m.text }; + } + return null; + } + if (type === "navigator") { + const m = msg as { open?: unknown }; + if (typeof m.open === "boolean") return { type: "navigator", open: m.open }; + return null; + } + if (type === "intervene") { + const m = msg as { action?: unknown; agentId?: unknown }; + if ((m.action === "pause" || m.action === "swap" || m.action === "spawn") && + (m.agentId === undefined || typeof m.agentId === "string")) { + return m.agentId === undefined + ? { type: "intervene", action: m.action } + : { type: "intervene", action: m.action, agentId: m.agentId }; + } + return null; + } + if (type === "revise") { + const m = msg as { id?: unknown }; + if (typeof m.id === "string") return { type: "revise", id: m.id }; + return null; + } + if (type === "open") { + const m = msg as { file?: unknown; line?: unknown }; + if (typeof m.file === "string" && (m.line === undefined || typeof m.line === "number")) { + return m.line === undefined ? { type: "open", file: m.file } : { type: "open", file: m.file, line: m.line }; + } + return null; + } + return null; +} + +// Drive the bridge from a validated frame, exactly as the WS handler did. +export function applyInbound(bridge: Bridge, f: InboundFrame): void { + switch (f.type) { + case "decide": + bridge.resolveDecision(f.id, f.choiceId); + return; + case "steer": + bridge.enqueueDirective(f.directive); + return; + case "launch": + bridge.requestLaunch(f.workflowId); + return; + case "navigate": + bridge.navigate(f.screen); + return; + case "answer": + bridge.resolveQuestion(f.id, f.text); + return; + case "navigator": + f.open ? bridge.openNavigator() : bridge.closeNavigator(); + return; + case "intervene": + bridge.intervene(f.action, f.agentId); + return; + case "revise": + bridge.revise(f.id); + return; + case "open": + bridge.enqueueDirective({ kind: "note", text: f.line != null ? `open ${f.file}:${f.line} in the editor` : `open ${f.file} in the editor` }); + return; + } +} diff --git a/rpi-cockpit/src/index.ts b/rpi-cockpit/src/index.ts new file mode 100644 index 000000000..46cdd2e3f --- /dev/null +++ b/rpi-cockpit/src/index.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env node +// rpi-cockpit/src/index.ts +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { homedir } from "node:os"; +import { Bridge } from "./bridge.js"; +import { startServer } from "./server.js"; +import { buildMcpServer, connectStdio } from "./mcp.js"; +import { runInit, type InitHost } from "./init.js"; +import { resolvePort } from "./port.js"; +import { liveStateDir } from "./paths.js"; +import { runLiveConsumer, tailInbox } from "./live.js"; + +// Compute the repo root once. dist/index.js -> rpi-cockpit -> repo root. +const entryPath = fileURLToPath(import.meta.url); +const root = resolve(dirname(entryPath), "..", ".."); + +// `rpi-cockpit init [--host claude|codex|vscode|all] [--codex-global]` wires the +// MCP server into the host surfaces and regenerates the narration contract. +// This MUST run before the MCP server starts; otherwise behave exactly as before. +if (process.argv[2] === "init") { + const argv = process.argv.slice(3); + let host: InitHost = "all"; + let codexGlobal = false; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === "--host") { + const v = argv[++i]; + if (v === "claude" || v === "codex" || v === "vscode" || v === "all") host = v; + } else if (a.startsWith("--host=")) { + const v = a.slice("--host=".length); + if (v === "claude" || v === "codex" || v === "vscode" || v === "all") host = v; + } else if (a === "--codex-global") { + codexGlobal = true; + } + } + + const contractPath = resolve(root, "rpi-cockpit", "agents", "cockpit-instructions.md"); + + const result = runInit({ + root, + entryPath, + contractPath, + host, + codexGlobal, + homeDir: homedir(), + }); + process.stdout.write(result.summary + "\n"); + process.exit(0); +} + +// `rpi-cockpit live` is the consumer pane (run by the host's .claude/launch.json): +// it mirrors the producer's state.json and routes user intents back via inbox.jsonl. +// It holds no authoritative state and never connects the MCP transport. +if (process.argv[2] === "live") { + const stateDir = process.env.RPI_COCKPIT_STATE_DIR ?? liveStateDir(root); + const port = resolvePort(process.env); + const srv = await runLiveConsumer({ stateDir, port }); + process.stderr.write(`rpi-cockpit live pane: ${srv.url} (state dir ${stateDir})\n`); + // Keep the process (and the pane server) alive. + setInterval(() => {}, 1 << 30); +} else { + const bridge = new Bridge(); + const port = resolvePort(process.env); + // The producer and consumer derive the SAME shared dir from the repo root so the + // file bridge lines up across the two processes a host launches. + const stateDir = process.env.RPI_COCKPIT_STATE_DIR ?? liveStateDir(root); + + // The UI server is best-effort: if it cannot bind, narration must still work, + // so the MCP stdio transport connects regardless of the server's outcome. + try { + const srv = await startServer(bridge, port, { stateDir, writeStateSnapshot: true }); + // Print the KEYED url: the per-session token must be carried as ?key=… or the + // HTTP/WS gates reject the connection. Without it the cockpit is unreachable. + process.stderr.write(`rpi-cockpit: ${srv.url}\n`); + // Also print the state dir: a host without the MCP connected can still read + // the user's steering from /directives.jsonl and decisions.jsonl. + process.stderr.write(`rpi-cockpit: state dir ${srv.stateDir}\n`); + // Tail the consumer's inbox so live-pane intents reach the agent's bridge. + // Only when the server bound; if it threw, MCP narration still works below. + tailInbox(stateDir, bridge); + } catch (err) { + const m = err instanceof Error ? err.message : String(err); + process.stderr.write(`rpi-cockpit: UI server unavailable (${m}); MCP narration still active.\n`); + } + + await connectStdio(buildMcpServer(bridge)); +} diff --git a/rpi-cockpit/src/init.ts b/rpi-cockpit/src/init.ts new file mode 100644 index 000000000..51cfb36da --- /dev/null +++ b/rpi-cockpit/src/init.ts @@ -0,0 +1,219 @@ +// rpi-cockpit/src/init.ts +// +// The `init` command wires the rpi-cockpit MCP server into every host surface +// (Claude Code, VS Code/Copilot, Codex) and regenerates the narration contract +// into the agent instruction files. Every write is idempotent: re-running must +// neither duplicate nor corrupt existing content. +import { + readFileSync, + writeFileSync, + mkdirSync, + existsSync, +} from "node:fs"; +import { join, dirname } from "node:path"; + +export type InitHost = "claude" | "codex" | "vscode" | "all"; + +export interface InitOptions { + /** Repository root that receives the project-scoped config files. */ + root: string; + /** Absolute path to the running dist/index.js (used by the Codex config). */ + entryPath: string; + /** Path to the canonical narration contract (agents/cockpit-instructions.md). */ + contractPath: string; + /** Which surfaces to wire. Defaults to "all". */ + host: InitHost; + /** When true, the Codex config goes to /.codex instead of /.codex. */ + codexGlobal: boolean; + /** Home directory (os.homedir()) — used only for the global Codex config. */ + homeDir: string; +} + +const SERVER_NAME = "rpi-cockpit"; +const NARRATION_BEGIN = ""; +const NARRATION_END = ""; + +function ensureDir(filePath: string): void { + mkdirSync(dirname(filePath), { recursive: true }); +} + +function readJsonIfExists(filePath: string): Record { + if (!existsSync(filePath)) return {}; + const raw = readFileSync(filePath, "utf8").trim(); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as Record) : {}; + } catch { + // A corrupt/unparseable file should not crash init; start fresh but keep nothing. + return {}; + } +} + +function writeJson(filePath: string, value: unknown): void { + ensureDir(filePath); + writeFileSync(filePath, JSON.stringify(value, null, 2) + "\n"); +} + +// (1) CLAUDE -> /.mcp.json — read-merge into mcpServers.rpi-cockpit. +function writeClaudeMcp(root: string): string { + const filePath = join(root, ".mcp.json"); + const doc = readJsonIfExists(filePath); + const servers = + doc.mcpServers && typeof doc.mcpServers === "object" + ? (doc.mcpServers as Record) + : {}; + servers[SERVER_NAME] = { + type: "stdio", + command: "node", + args: ["${CLAUDE_PROJECT_DIR}/rpi-cockpit/dist/index.js"], + env: { RPI_COCKPIT_PORT: "4399" }, + }; + doc.mcpServers = servers; + writeJson(filePath, doc); + return filePath; +} + +// (2) VSCODE -> /.vscode/mcp.json — read-merge into servers.rpi-cockpit. +function writeVscodeMcp(root: string): string { + const filePath = join(root, ".vscode", "mcp.json"); + const doc = readJsonIfExists(filePath); + const servers = + doc.servers && typeof doc.servers === "object" + ? (doc.servers as Record) + : {}; + servers[SERVER_NAME] = { + type: "stdio", + command: "node", + args: ["${workspaceFolder}/rpi-cockpit/dist/index.js"], + cwd: "${workspaceFolder}", + }; + doc.servers = servers; + writeJson(filePath, doc); + return filePath; +} + +// (3) CODEX -> /.codex/config.toml (or /.codex when codexGlobal). +// Hand-rolled TOML: replace exactly the [mcp_servers.rpi-cockpit] table if present, +// else append it. +function buildCodexTable(entryPath: string, cwd: string): string { + return [ + `[mcp_servers.${SERVER_NAME}]`, + `command = "node"`, + `args = ["${entryPath}"]`, + `cwd = "${cwd}"`, + `startup_timeout_sec = 20`, + ].join("\n"); +} + +function upsertCodexTable(existing: string, table: string): string { + const header = `[mcp_servers.${SERVER_NAME}]`; + const headerIdx = existing.indexOf(header); + if (headerIdx === -1) { + // Append, separated by a blank line if there's prior content. + const trimmed = existing.replace(/\s+$/, ""); + return trimmed.length ? `${trimmed}\n\n${table}\n` : `${table}\n`; + } + // Replace from the header line up to (but not including) the next table header + // or end-of-file. Table headers start a line with "[". + const before = existing.slice(0, headerIdx); + const rest = existing.slice(headerIdx + header.length); + const nextHeaderRel = rest.search(/\n\[/); + const after = nextHeaderRel === -1 ? "" : rest.slice(nextHeaderRel + 1); // drop the leading \n + const beforeTrimmed = before.replace(/\s+$/, ""); + const prefix = beforeTrimmed.length ? `${beforeTrimmed}\n\n` : ""; + const suffix = after.length ? `\n${after.replace(/^\s+/, "")}` : "\n"; + return `${prefix}${table}${suffix}`; +} + +function writeCodexConfig(root: string, homeDir: string, codexGlobal: boolean, entryPath: string): string { + const base = codexGlobal ? homeDir : root; + const filePath = join(base, ".codex", "config.toml"); + const existing = existsSync(filePath) ? readFileSync(filePath, "utf8") : ""; + const table = buildCodexTable(entryPath, root); + const next = upsertCodexTable(existing, table); + ensureDir(filePath); + writeFileSync(filePath, next); + return filePath; +} + +// (4) NARRATION -> marker-delimited, inlined contract text into a Markdown surface. +function buildNarrationBlock(contractText: string): string { + return `${NARRATION_BEGIN}\n${contractText.trim()}\n${NARRATION_END}`; +} + +// Everything in `text` except an existing narration block, so we can tell a +// block-only surface (CLAUDE.md, AGENTS.md) from one with its own content. +function stripBlock(text: string): string { + const b = text.indexOf(NARRATION_BEGIN); + const e = text.indexOf(NARRATION_END); + if (b !== -1 && e !== -1 && e > b) return text.slice(0, b) + text.slice(e + NARRATION_END.length); + return text; +} + +// Demote every ATX heading (h1..h5) by one level. Used when the block is +// embedded in a host doc that already has its own H1 title, so the contract's +// `# Cockpit instrumentation` becomes `## …` and does not introduce a second +// top-level heading (markdownlint MD025). h6 is left as-is (no h7). +function demoteHeadings(md: string): string { + return md.split("\n").map((line) => (/^#{1,5}\s/.test(line) ? "#" + line : line)).join("\n"); +} + +function writeNarration(filePath: string, contractText: string): string { + const existing = existsSync(filePath) ? readFileSync(filePath, "utf8") : ""; + // Block-only surfaces keep the H1 (so they satisfy MD041); a surface with its + // own content gets the headings demoted (so the block nests under its title). + const embedded = stripBlock(existing).trim().length > 0; + const block = buildNarrationBlock(embedded ? demoteHeadings(contractText) : contractText); + let next: string; + const beginIdx = existing.indexOf(NARRATION_BEGIN); + const endIdx = existing.indexOf(NARRATION_END); + if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) { + // Replace exactly the existing block (markers included). + const before = existing.slice(0, beginIdx); + const after = existing.slice(endIdx + NARRATION_END.length); + next = `${before}${block}${after}`; + } else if (existing.trim().length) { + // Append after existing content, preserving it. + next = `${existing.replace(/\s+$/, "")}\n\n${block}\n`; + } else { + next = `${block}\n`; + } + ensureDir(filePath); + writeFileSync(filePath, next); + return filePath; +} + +export interface InitResult { + written: string[]; + summary: string; +} + +export function runInit(opts: InitOptions): InitResult { + const { root, entryPath, contractPath, host, codexGlobal, homeDir } = opts; + const contractText = readFileSync(contractPath, "utf8"); + const written: string[] = []; + + const doClaude = host === "all" || host === "claude"; + const doVscode = host === "all" || host === "vscode"; + const doCodex = host === "all" || host === "codex"; + + // Config writers. + if (doClaude) written.push(writeClaudeMcp(root)); + if (doVscode) written.push(writeVscodeMcp(root)); + if (doCodex) written.push(writeCodexConfig(root, homeDir, codexGlobal, entryPath)); + + // Narration surfaces: + // claude -> CLAUDE.md ; vscode -> .github/copilot-instructions.md ; codex -> AGENTS.md + if (doClaude) written.push(writeNarration(join(root, "CLAUDE.md"), contractText)); + if (doCodex) written.push(writeNarration(join(root, "AGENTS.md"), contractText)); + if (doVscode) + written.push(writeNarration(join(root, ".github", "copilot-instructions.md"), contractText)); + + const summary = [ + `rpi-cockpit init (--host ${host}${codexGlobal ? " --codex-global" : ""}) wrote:`, + ...written.map((f) => ` - ${f}`), + ].join("\n"); + + return { written, summary }; +} diff --git a/rpi-cockpit/src/live.ts b/rpi-cockpit/src/live.ts new file mode 100644 index 000000000..c1a38f683 --- /dev/null +++ b/rpi-cockpit/src/live.ts @@ -0,0 +1,112 @@ +// rpi-cockpit/src/live.ts +// The file-backed cross-process bridge. The producer (MCP server) writes +// state.json and tails inbox.jsonl; the consumer (`dist/index.js live`) mirrors +// state.json into a holder bridge and appends user intents to inbox.jsonl. +import fs from "node:fs"; +import { mkdirSync, appendFileSync, readFileSync, readSync, openSync, closeSync, fstatSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { Bridge } from "./bridge.js"; +import { startServer } from "./server.js"; +import { initialState, type SessionState } from "./state.js"; +import { parseInbound, applyInbound, type InboundFrame } from "./inbound.js"; + +// Consumer: serve the cockpit UI in embed mode, mirror state.json into a holder +// bridge, and route inbound frames to inbox.jsonl for the producer to apply. +export async function runLiveConsumer( + opts: { stateDir: string; port: number }, +): Promise<{ port: number; url: string; close: () => Promise }> { + const { stateDir, port } = opts; + mkdirSync(stateDir, { recursive: true }); + + // A holder bridge: not authoritative. It only carries the latest snapshot to + // connected pane clients via the server's broadcast on "state". + const bridge = new Bridge(); + const stateFile = join(stateDir, "state.json"); + const inboxFile = join(stateDir, "inbox.jsonl"); + + // Route every inbound frame to inbox.jsonl instead of mutating the holder. + const appendInbox = (f: InboundFrame): void => { + try { appendFileSync(inboxFile, JSON.stringify(f) + "\n"); } catch { /* best-effort */ } + }; + + const srv = await startServer(bridge, port, { + trustLoopback: true, + stateDir, + onInbound: appendInbox, + }); + + // Load the snapshot off disk and broadcast it to pane clients. Guard the read + // and parse so a missing or half-written file is simply skipped. + const load = (): void => { + try { + // Merge over initialState so a snapshot written by an older producer (missing + // fields a newer view-model reads, e.g. galleryItems) is backfilled, not a crash. + const parsed = JSON.parse(readFileSync(stateFile, "utf8")) as Partial; + const st = { ...initialState(), ...parsed } as SessionState; + bridge.state = st; + bridge.emit("state", st); + } catch { /* no snapshot yet, or mid-write; try again on the next tick */ } + }; + load(); + // Polling watch for cross-platform reliability (fs.watch misses on some hosts). + fs.watchFile(stateFile, { interval: 150 }, load); + + return { + port: srv.port, + url: srv.url, + close: async () => { + fs.unwatchFile(stateFile, load); + await srv.close(); + }, + }; +} + +// Producer: tail inbox.jsonl from a byte offset, applying each complete line to +// the agent's authoritative bridge. A partial trailing line is buffered until the +// rest of it is appended. +export function tailInbox(stateDir: string, bridge: Bridge): { stop: () => void } { + const inboxFile = join(stateDir, "inbox.jsonl"); + // Seek to the end on startup: a producer (re)start must not replay intents the + // user steered in an earlier session. Only frames appended after we begin + // tailing are applied. inbox.jsonl is strictly append-only, so the current + // size is exactly "everything so far". + let offset = ((): number => { try { return statSync(inboxFile).size; } catch { return 0; } })(); + let buffer = ""; + + const check = (): void => { + try { + const fd = openSync(inboxFile, "r"); + try { + const size = fstatSync(fd).size; + if (size <= offset) { + // File truncated/replaced shorter than our offset: reset to re-read. + if (size < offset) { offset = 0; buffer = ""; } + else return; + } + const len = size - offset; + const buf = Buffer.alloc(len); + const read = readSync(fd, buf, 0, len, offset); + offset += read; + buffer += buf.toString("utf8", 0, read); + } finally { + closeSync(fd); + } + } catch { return; } // file not present yet, or transient fs error + + // Split into complete lines; keep the trailing partial for next time. + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const line of lines) { + if (line.trim() === "") continue; + let parsed: unknown; + try { parsed = JSON.parse(line); } catch { continue; } + const f = parseInbound(parsed); + if (f) applyInbound(bridge, f); + } + }; + + check(); + fs.watchFile(inboxFile, { interval: 150 }, check); + + return { stop: () => fs.unwatchFile(inboxFile, check) }; +} diff --git a/rpi-cockpit/src/mcp.ts b/rpi-cockpit/src/mcp.ts new file mode 100644 index 000000000..30c0e1ad9 --- /dev/null +++ b/rpi-cockpit/src/mcp.ts @@ -0,0 +1,330 @@ +// rpi-cockpit/src/mcp.ts +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { Phase, ValidationStatus, OptionItem, Severity, AgentStatus, CodeKind, TouchKind } from "./events.js"; +import { handlers } from "./handlers.js"; +import { presentOptionsWithElicitation, askQuestionWithElicitation, presentWorkflows, decisionTimeoutMs, questionTimeoutMs, type ElicitFormParams } from "./elicit.js"; +import type { Bridge } from "./bridge.js"; + +const text = (s: string) => ({ content: [{ type: "text" as const, text: s }] }); + +export function buildMcpServer(bridge: Bridge): McpServer { + const server = new McpServer({ name: "rpi-cockpit", version: "0.1.0" }); + + server.registerTool( + "session_begin", + { description: "Open the cockpit session.", inputSchema: { task: z.string(), host: z.string() } }, + async (a) => text(handlers.session_begin(bridge, a)), + ); + + server.registerTool( + "phase_enter", + { description: "Enter an RPI phase.", inputSchema: { phase: Phase } }, + async (a) => text(handlers.phase_enter(bridge, a)), + ); + + server.registerTool( + "subagent_start", + { description: "Mark a subagent active.", inputSchema: { name: z.string(), role: z.string().optional() } }, + async (a) => text(handlers.subagent_start(bridge, a)), + ); + + server.registerTool( + "subagent_stop", + { description: "Mark a subagent idle.", inputSchema: { name: z.string(), result: z.string().optional() } }, + async (a) => text(handlers.subagent_stop(bridge, a)), + ); + + server.registerTool( + "artifact_update", + { description: "Record a tracking artifact.", inputSchema: { path: z.string(), summary: z.string().optional() } }, + async (a) => text(handlers.artifact_update(bridge, a)), + ); + + server.registerTool( + "validate", + { description: "Report a validation check.", inputSchema: { check: z.string(), status: ValidationStatus } }, + async (a) => text(handlers.validate(bridge, a)), + ); + + server.registerTool( + "review_start", + { description: "Begin a review; switches the cockpit to the findings panel.", inputSchema: { target: z.string() } }, + async (a) => text(handlers.review_start(bridge, a)), + ); + + server.registerTool( + "add_finding", + { description: "Add a review finding (rendered in the findings panel, grouped by severity).", inputSchema: { severity: Severity, title: z.string(), file: z.string().optional(), line: z.number().int().optional(), detail: z.string().optional() } }, + async (a) => text(handlers.add_finding(bridge, a)), + ); + + server.registerTool( + "interview_start", + { description: "Begin a guided document interview; switches the cockpit to the interview view.", inputSchema: { docType: z.string() } }, + async (a) => text(handlers.interview_start(bridge, a)), + ); + + server.registerTool( + "set_steps", + { description: "Show a progress stepper above the interview conversation: declare the program's ordered step names and the active step index (0-based). Re-call to advance (a higher current) or to re-declare the steps as an adaptive program clarifies. label is an optional program name. progress is an optional { done, total } shown on the active step.", inputSchema: { steps: z.array(z.string()).min(1), current: z.number().int(), label: z.string().optional(), progress: z.object({ done: z.number().int(), total: z.number().int() }).optional() } }, + async (a) => text(handlers.set_steps(bridge, a)), + ); + + server.registerTool( + "backlog_start", + { description: "Begin a backlog board; switches the cockpit to the kanban view. Declare the ordered column/state names (e.g. Triage, Todo, In progress, Done).", inputSchema: { target: z.string(), columns: z.array(z.string()).min(1) } }, + async (a) => text(handlers.backlog_start(bridge, a)), + ); + + server.registerTool( + "add_item", + { description: "Add or update a work item on the backlog board, placing it in the given column. Pass parent (a parent item's id) to nest it under that item in the hierarchy.", inputSchema: { id: z.string(), title: z.string(), column: z.string(), kind: z.string().optional(), tier: z.string().optional(), parent: z.string().optional() } }, + async (a) => text(handlers.add_item(bridge, a)), + ); + + server.registerTool( + "move_item", + { description: "Move a work item to a different column on the board.", inputSchema: { id: z.string(), column: z.string() } }, + async (a) => text(handlers.move_item(bridge, a)), + ); + + server.registerTool( + "set_backlog_action", + { description: "Set or clear the current action line in the board header (pass null to clear).", inputSchema: { text: z.string().nullable() } }, + async (a) => text(handlers.set_backlog_action(bridge, a)), + ); + + server.registerTool( + "dataset_profile", + { description: "Begin a dataset profile; switches the cockpit to the data-profile table view. Name the dataset; optionally give its row count, total column count, and source.", inputSchema: { name: z.string(), rows: z.number().int().optional(), columns: z.number().int().optional(), source: z.string().optional() } }, + async (a) => text(handlers.dataset_profile(bridge, a)), + ); + + server.registerTool( + "add_column", + { description: "Add or update one column's profile in the dataset profile view (a dataset field, not a kanban column). Give its name and dtype; optionally null percentage (0-100), distinct count, a representative stat string (e.g. \"0-4820\" or \"mean 126.2\"), and a quality flag (ok/warn/risk).", inputSchema: { name: z.string(), dtype: z.string(), nullPct: z.number().optional(), distinct: z.number().int().optional(), stat: z.string().optional(), quality: z.enum(["ok", "warn", "risk"]).optional() } }, + async (a) => text(handlers.add_column(bridge, a)), + ); + + server.registerTool( + "set_context", + { description: "Set the active context shown in the cockpit's context strip: the instructions (coding standards), skills, and collection currently in effect. Replaces the whole context, so pass everything currently active. Omitting instructions, skills, or collection clears that group.", inputSchema: { instructions: z.array(z.string()).optional(), skills: z.array(z.string()).optional(), collection: z.string().nullable().optional() } }, + async (a) => text(handlers.set_context(bridge, a)), + ); + + server.registerTool( + "set_app_frame", + { description: "Embed the user's app-under-development in a trusted iframe beside the cockpit. The URL MUST be a loopback http(s) URL (localhost / 127.0.0.1 / [::1]); non-loopback URLs are rejected. Pass null to clear the frame.", inputSchema: { url: z.string().nullable() } }, + async (a) => text(handlers.set_app_frame(bridge, a)), + ); + + server.registerTool( + "team_start", + { description: "Begin a team-orchestration run; switches the cockpit to the team board. Names the orchestrator (lead) and the overall task.", inputSchema: { task: z.string(), orchestrator: z.string() } }, + async (a) => text(handlers.team_start(bridge, a)), + ); + + server.registerTool( + "add_agent", + { description: "Add a subagent to the team board with a status (queued/running/blocked/done/failed).", inputSchema: { id: z.string(), name: z.string(), role: z.string().optional(), status: AgentStatus } }, + async (a) => text(handlers.add_agent(bridge, a)), + ); + + server.registerTool( + "update_agent", + { description: "Update a team subagent's status and/or current action.", inputSchema: { id: z.string(), status: AgentStatus.optional(), action: z.string().nullable().optional() } }, + async (a) => text(handlers.update_agent(bridge, a)), + ); + + server.registerTool( + "remove_agent", + { description: "Remove a subagent from the team board.", inputSchema: { id: z.string() } }, + async (a) => text(handlers.remove_agent(bridge, a)), + ); + + server.registerTool( + "codemap_set", + { description: "Set the codebase map: the slice of files/dirs relevant to this task (max 60). Switches the cockpit to the 3D codebase map.", inputSchema: { nodes: z.array(z.object({ id: z.string(), path: z.string(), kind: CodeKind, group: z.string().optional() })).max(60) } }, + async (a) => text(handlers.codemap_set(bridge, a)), + ); + + server.registerTool( + "codemap_focus", + { description: "Move the codebase-map camera to a node (the file the agent is now working in).", inputSchema: { id: z.string() } }, + async (a) => text(handlers.codemap_focus(bridge, a)), + ); + + server.registerTool( + "codemap_touch", + { description: "Mark a codebase-map node as read or edited (the agent's trail).", inputSchema: { id: z.string(), kind: TouchKind } }, + async (a) => text(handlers.codemap_touch(bridge, a)), + ); + + server.registerTool( + "ask_question", + { description: "Ask the user a free-text question; blocks until they answer. Shows the in-pane question card and, where supported, a native input.", inputSchema: { prompt: z.string(), id: z.string().optional() } }, + async (a) => { + bridge.setHostElicits(server.server.getClientCapabilities()?.elicitation !== undefined); + return text( + await askQuestionWithElicitation( + { + getClientCapabilities: () => server.server.getClientCapabilities(), + elicitInput: (params: ElicitFormParams, opts) => + server.server.elicitInput(params as unknown as Parameters[0], opts), + }, + bridge, + a.prompt, + // Free-text answers default to no auto-resolve (see questionTimeoutMs): + // an empty timeout fallback is indistinguishable from a deliberate empty + // answer, so let the interactive interview block until the user answers. + questionTimeoutMs(), + a.id, + ), + ); + }, + ); + + server.registerTool( + "present_options", + { description: "Ask the user to choose; blocks until they pick. Shows the in-pane card and, where the host supports it, a native choice card; the first answer wins.", inputSchema: { prompt: z.string(), options: z.array(OptionItem).min(1), id: z.string().optional() } }, + async (a) => { + bridge.setHostElicits(server.server.getClientCapabilities()?.elicitation !== undefined); + return text( + await presentOptionsWithElicitation( + { + getClientCapabilities: () => server.server.getClientCapabilities(), + elicitInput: (params: ElicitFormParams, opts) => + server.server.elicitInput(params as unknown as Parameters[0], opts), + }, + bridge, + a.prompt, + a.options, + decisionTimeoutMs(), + a.id, + ), + ); + }, + ); + + server.registerTool( + "offer_approaches", + { description: "Offer the user a structured choice for the next phase (populates the cockpit's Steer select).", inputSchema: { label: z.string(), options: z.array(OptionItem).min(1) } }, + async (a) => text(handlers.offer_approaches(bridge, a)), + ); + + server.registerTool( + "check_directives", + { description: "Pull any user directives queued from the cockpit. Returns them as text; you MUST read and act on the result. Call at each phase_enter.", inputSchema: {} }, + async () => text(handlers.check_directives(bridge)), + ); + + server.registerTool( + "show_screen", + { description: "Render arbitrary static HTML in a dedicated, sandboxed cockpit pane (no scripts run; isolated from the page). For one-off rich content the fixed cockpit can't show: a mockup, a diff, rendered markdown, a diagram.", inputSchema: { html: z.string(), title: z.string().optional() } }, + async (a) => text(handlers.show_screen(bridge, a)), + ); + + server.registerTool( + "clear_screen", + { description: "Remove the agent-authored screen pane from the cockpit.", inputSchema: {} }, + async () => text(handlers.clear_screen(bridge)), + ); + + server.registerTool( + "open_navigator", + { description: "Open the Navigator pop-up in the cockpit so the user can pick a workflow. Use for a /Nav command or at the start of the main skill.", inputSchema: {} }, + async () => text(handlers.open_navigator(bridge)), + ); + + const galleryItemSchema = z.object({ id: z.string().optional(), label: z.string(), group: z.string().optional(), url: z.string().optional(), html: z.string().optional(), caption: z.string().optional() }); + + server.registerTool( + "gallery_open", + { description: "Open the gallery view: a scrollable grid of scaled live thumbnails. Pass a title and an array of items; each item is EITHER a live `url` (a website or loopback dev server, framed live) OR an inline `html` snapshot, plus a `label`, optional `group` (section header), and optional `caption`. `url` must be a loopback http(s) URL or an external https URL. `size` is s/m/l (default m).", inputSchema: { title: z.string(), items: z.array(galleryItemSchema), size: z.enum(["s", "m", "l"]).optional() } }, + async (a) => text(handlers.gallery_open(bridge, a)), + ); + + server.registerTool( + "gallery_add", + { description: "Add or update one gallery tile by id (upsert). The item has the same shape as a gallery_open item (label, one of url/html, optional group/caption). Use this to stream tiles into an open gallery.", inputSchema: { item: galleryItemSchema } }, + async (a) => text(handlers.gallery_add(bridge, a)), + ); + + server.registerTool( + "gallery_clear", + { description: "Remove all tiles from the gallery (the view stays open with its title).", inputSchema: {} }, + async () => text(handlers.gallery_clear(bridge)), + ); + + server.registerTool( + "promptlab_start", + { description: "Begin a prompt workbench (the behavior test bench); switches the cockpit to the promptlab view. Name the prompt being hardened; optionally give its current text and the iteration round (default 1). Re-call with round+1 for a fresh pass.", inputSchema: { name: z.string(), prompt: z.string().optional(), round: z.number().int().optional() } }, + async (a) => text(handlers.promptlab_start(bridge, a)), + ); + + server.registerTool( + "add_case", + { description: "Add or update one prompt TEST CASE in the workbench (a scenario the prompt is run on, not a kanban item). Give an id and the scenario; once the Tester runs it and the Evaluator judges, update the same id with the literal output, a verdict (pending/running/pass/warn/fail), and an optional note.", inputSchema: { id: z.string(), scenario: z.string(), output: z.string().optional(), verdict: z.enum(["pending", "running", "pass", "warn", "fail"]).optional(), note: z.string().optional() } }, + async (a) => text(handlers.add_case(bridge, a)), + ); + + server.registerTool( + "memory_open", + { description: "Open the Memory view and switch the cockpit to it. Optionally name the collection (e.g. a project memory name). Clears the entries and handoffs for a fresh session view.", inputSchema: { title: z.string().optional() } }, + async (a) => text(handlers.memory_open(bridge, a)), + ); + + server.registerTool( + "add_memory", + { description: "Add or update one MEMORY ENTRY (a recalled or written fact, not a kanban item / dataset column / prompt case). Give an id, its content, and a category to group by (a memory type like user/feedback/project/reference, or a source); optionally a short title and a tag: recalled (loaded into context), added (written this session), or updated.", inputSchema: { id: z.string(), content: z.string(), category: z.string(), tag: z.enum(["recalled", "added", "updated"]).optional(), title: z.string().optional() } }, + async (a) => text(handlers.add_memory(bridge, a)), + ); + + server.registerTool( + "add_handoff", + { description: "Add or update one memory HANDOFF: another agent handing state to Memory. Give an id, `from` (the handing-off agent's name), a summary of what was handed, and an action: stored, merged, or recalled.", inputSchema: { id: z.string(), from: z.string(), summary: z.string(), action: z.enum(["stored", "merged", "recalled"]).optional() } }, + async (a) => text(handlers.add_handoff(bridge, a)), + ); + + server.registerTool( + "flow_open", + { description: "Open the flow canvas (the gh-aw agentic-workflow pipeline as a node graph) and switch the cockpit to it. Optionally name the pipeline. Clears nodes/edges and the drill focus.", inputSchema: { title: z.string().optional() } }, + async (a) => text(handlers.flow_open(bridge, a)), + ); + server.registerTool( + "add_flow_node", + { description: "Add or update one FLOW NODE (a node in the pipeline graph, not a kanban item). kind is workflow (an orchestration-level workflow) or trigger/guard/agent/output/mcp (an anatomy element inside one workflow). For an anatomy node set scope to the workflow node's id; orchestration nodes leave scope default. status (idle/running/passed/failed/skipped/stale) drives the live-run look; sub is a short subtitle.", inputSchema: { id: z.string(), kind: z.enum(["workflow", "trigger", "guard", "agent", "output", "mcp"]), label: z.string(), scope: z.string().optional(), sub: z.string().optional(), status: z.enum(["idle", "running", "passed", "failed", "skipped", "stale"]).optional() } }, + async (a) => text(handlers.add_flow_node(bridge, a)), + ); + server.registerTool( + "add_flow_edge", + { description: "Add or update one FLOW EDGE between two node ids. kind: label or event or output (orchestration handoffs) or step (anatomy). label is the handoff (e.g. a label name like agent-ready). status active animates the edge during a live run. Set scope to a workflow id for an anatomy edge; orchestration edges leave scope default.", inputSchema: { id: z.string(), from: z.string(), to: z.string(), scope: z.string().optional(), label: z.string().optional(), kind: z.enum(["label", "event", "output", "step"]).optional(), status: z.enum(["idle", "active"]).optional() } }, + async (a) => text(handlers.add_flow_edge(bridge, a)), + ); + server.registerTool( + "flow_focus", + { description: "Drill the flow canvas to a workflow's anatomy by its node id, or omit / pass null to return to the orchestration pipeline. Use during a debug narration to pull the pane to a failing workflow.", inputSchema: { workflow: z.string().nullable().optional() } }, + async (a) => text(handlers.flow_focus(bridge, a)), + ); + + server.registerTool( + "present_workflows", + { description: "Offer the user the HVE Core workflows as a native choice card and return the chosen workflow's launch instruction. Use to let the user pick what to do.", inputSchema: {} }, + async () => + text( + await presentWorkflows({ + getClientCapabilities: () => server.server.getClientCapabilities(), + elicitInput: (params: ElicitFormParams, opts) => + server.server.elicitInput(params as unknown as Parameters[0], opts), + }), + ), + ); + + return server; +} + +export async function connectStdio(server: McpServer): Promise { + await server.connect(new StdioServerTransport()); +} diff --git a/rpi-cockpit/src/paths.ts b/rpi-cockpit/src/paths.ts new file mode 100644 index 000000000..53bb3f81d --- /dev/null +++ b/rpi-cockpit/src/paths.ts @@ -0,0 +1,13 @@ +// rpi-cockpit/src/paths.ts +import os from "node:os"; +import path from "node:path"; +import crypto from "node:crypto"; + +// Both the producer (the MCP server) and the consumer (`dist/index.js live`) +// derive the SAME shared dir from the repo root, with no host env interpolation. +// A short hash of the absolute repo root keys the dir so two repos on one host +// never collide, while one repo's two processes always agree on the path. +export function liveStateDir(repoRoot: string): string { + const hash = crypto.createHash("sha1").update(repoRoot).digest("hex").slice(0, 12); + return path.join(os.tmpdir(), "rpi-cockpit", "live", hash); +} diff --git a/rpi-cockpit/src/port.ts b/rpi-cockpit/src/port.ts new file mode 100644 index 000000000..518ef3191 --- /dev/null +++ b/rpi-cockpit/src/port.ts @@ -0,0 +1,17 @@ +// Resolve the port the cockpit should bind. Precedence, highest first: +// 1. PORT — assigned by the host pane (Claude Preview, VS Code). +// 2. RPI_COCKPIT_PORT — our own override for standalone runs. +// 3. 4399 — the stable default. +// A candidate counts only if it is a finite integer in the valid TCP range +// 1..65535. Anything else (empty, NaN, 0, negative, > 65535, fractional) is +// treated as absent and we fall through to the next source. +function validPort(raw: string | undefined): number | null { + if (raw === undefined || raw === "") return null; + const n = Number(raw); + if (!Number.isInteger(n) || n < 1 || n > 65535) return null; + return n; +} + +export function resolvePort(env: Record): number { + return validPort(env.PORT) ?? validPort(env.RPI_COCKPIT_PORT) ?? 4399; +} diff --git a/rpi-cockpit/src/render.ts b/rpi-cockpit/src/render.ts new file mode 100644 index 000000000..23b11af2d --- /dev/null +++ b/rpi-cockpit/src/render.ts @@ -0,0 +1,231 @@ +// rpi-cockpit/src/render.ts +import type { SessionState, BacklogItem } from "./state.js"; +import type { Phase, OptionItem, Directive, Severity } from "./events.js"; +import { WORKFLOWS } from "./catalog.js"; + +const ORDER: Phase[] = ["research", "plan", "implement", "review", "discover"]; +const LABEL: Record = { research: "Research", plan: "Plan", implement: "Implement", review: "Review", discover: "Discover" }; +const LEAD: Record = { + research: "Gathering context and constraints before committing to a plan.", + plan: "Turning research into an ordered, reviewable plan.", + implement: "Executing the plan. Subagents and validation run here; the loop won't advance until checks pass.", + review: "Verifying the work against the plan and the validation gate.", + discover: "Surfacing follow-up work uncovered during the cycle.", +}; +const SEVERITY_ORDER: Severity[] = ["critical", "high", "medium", "low", "info"]; +const AGENT_STATUS_ORDER = ["running", "blocked", "queued", "done", "failed"] as const; +const AGENT_STATUS_LABEL: Record = { running: "Running", blocked: "Blocked", queued: "Queued", done: "Done", failed: "Failed" }; +const EMPTY_LEAD = "Waiting for an RPI session… the cockpit is connected and lights up when the agent calls session_begin."; +const PRESETS: { id: string; title: string }[] = [ + { id: "default", title: "Default" }, + { id: "thorough", title: "Be more thorough" }, + { id: "faster", title: "Move faster" }, + { id: "ask-first", title: "Ask before big changes" }, +]; + +function orderColumnItems( + columnItems: BacklogItem[], + byId: Map, + inColumn: Set, +): { id: string; title: string; kind?: string; tier?: string; depth: number; parentRef?: string }[] { + const childrenOf = new Map(); + const roots: BacklogItem[] = []; + for (const i of columnItems) { + const sameColParent = i.parent && inColumn.has(i.parent) ? i.parent : null; + if (sameColParent) { + const arr = childrenOf.get(sameColParent) ?? []; + arr.push(i); + childrenOf.set(sameColParent, arr); + } else { + roots.push(i); + } + } + const out: { id: string; title: string; kind?: string; tier?: string; depth: number; parentRef?: string }[] = []; + const seen = new Set(); + const visit = (i: BacklogItem, depth: number): void => { + if (seen.has(i.id)) return; + seen.add(i.id); + const crossColumn = i.parent !== undefined && !inColumn.has(i.parent); + out.push({ + id: i.id, title: i.title, kind: i.kind, tier: i.tier, depth, + parentRef: crossColumn ? (byId.get(i.parent as string)?.title ?? (i.parent as string)) : undefined, + }); + for (const c of childrenOf.get(i.id) ?? []) visit(c, depth + 1); + }; + for (const r of roots) visit(r, 0); + for (const i of columnItems) if (!seen.has(i.id)) visit(i, 0); // defensive: cycles/orphans never silently drop + return out; +} + +export interface StepVM { phase: Phase; status: "done" | "active" | "pending"; } +export interface SteerMenuVM { label: string; source: "agent" | "preset"; options: { id: string; title: string; detail?: string }[]; } +export interface ViewModel { + started: boolean; + task: string; + host: string; + domain: "rpi" | "review" | "interview" | "backlog" | "team" | "codemap" | "dataprofile" | "gallery" | "promptlab" | "memory" | "flow" | null; + reviewTarget: string | null; + findingGroups: { severity: Severity; items: { title: string; file?: string; line?: number; detail?: string }[] }[]; + board: { target: string | null; action: string | null; count: number; columns: { name: string; items: { id: string; title: string; kind?: string; tier?: string; depth: number; parentRef?: string }[] }[] }; + team: { orchestrator: string | null; count: number; columns: { status: string; label: string; agents: { id: string; name: string; role?: string; action?: string | null }[] }[] }; + codemap: { nodes: { id: string; path: string; kind: string; group: string }[]; focus: string | null; touches: Record }; + dataProfile: { dataset: { name: string; rows?: number; cols?: number; source?: string } | null; columns: { name: string; dtype: string; nullPct?: number; distinct?: number; stat?: string; quality?: string }[] }; + gallery: { title: string | null; size: "s" | "m" | "l"; items: { id: string; label: string; group: string | null; kind: "url" | "html" | "empty"; src: string | null; caption: string | null }[] }; + promptlab: { name: string | null; round: number; prompt: string | null; summary: { pass: number; warn: number; fail: number; pending: number; running: number; total: number }; cases: { id: string; scenario: string; output: string | null; verdict: string; note: string | null }[] }; + memory: { title: string | null; counts: { recalled: number; added: number; updated: number; total: number }; entries: { id: string; title: string | null; content: string; category: string; tag: string }[]; handoffs: { id: string; from: string; summary: string; action: string }[] }; + flow: { title: string | null; focus: string | null; nodes: { id: string; scope: string; kind: string; label: string; sub: string | null; status: string }[]; edges: { id: string; from: string; to: string; scope: string; label: string | null; kind: string; status: string }[] }; + view: "home" | "loop"; + navigatorOpen: boolean; + workflows: { id: string; name: string; hint: string; description: string }[]; + activeWorkflow: string | null; + phase: Phase | null; + phaseLabel: string | null; + phaseNumber: number | null; + lead: string; + steps: StepVM[]; + subagents: { name: string; status: string; role?: string }[]; + validations: { check: string; status: string }[]; + docType: string | null; + interviewSteps: { label?: string; steps: { name: string; status: "done" | "active" | "pending"; progress?: { done: number; total: number } }[] } | null; + decisions: { id: string; prompt: string; kind: string; options?: { id: string; title: string; detail?: string; recommended?: boolean }[]; answer?: string; status: string }[]; + hostElicits: boolean; + steerMenu: SteerMenuVM; + directives: Directive[]; + screen: SessionState["screen"]; + context: { instructions: string[]; skills: string[]; collection: string | null }; + appFrame: { url: string | null }; + log: SessionState["log"]; +} + +export function toViewModel(s: SessionState): ViewModel { + const steps: StepVM[] = ORDER.map((phase) => ({ + phase, + status: s.phase === phase ? "active" : s.phasesDone.includes(phase) ? "done" : "pending", + })); + const idx = s.phase ? ORDER.indexOf(s.phase) : -1; + const steerMenu: SteerMenuVM = s.steerMenu + ? { label: s.steerMenu.label, source: "agent", options: s.steerMenu.options.map((o: OptionItem) => ({ id: o.id, title: o.title, detail: o.detail })) } + : { label: "Next-phase approach", source: "preset", options: PRESETS.map((o) => ({ id: o.id, title: o.title })) }; + const findingGroups = SEVERITY_ORDER + .map((severity) => ({ + severity, + items: s.findings + .filter((f) => f.severity === severity) + .map((f) => ({ title: f.title, file: f.file, line: f.line, detail: f.detail })), + })) + .filter((g) => g.items.length > 0); + const byId = new Map(s.boardItems.map((i) => [i.id, i])); + const board = { + target: s.boardTarget, + action: s.boardAction, + count: s.boardItems.length, + columns: s.boardColumns.map((name) => { + const columnItems = s.boardItems.filter((i) => i.column === name); + const inColumn = new Set(columnItems.map((i) => i.id)); + return { name, items: orderColumnItems(columnItems, byId, inColumn) }; + }), + }; + const team = { + orchestrator: s.orchestrator, + count: s.teamAgents.length, + columns: AGENT_STATUS_ORDER + .map((status) => ({ + status, + label: AGENT_STATUS_LABEL[status], + agents: s.teamAgents + .filter((a) => a.status === status) + .map((a) => ({ id: a.id, name: a.name, role: a.role, action: a.action })), + })) + .filter((c) => c.agents.length > 0), + }; + const codemap = { + nodes: s.codemapNodes.map((n) => ({ + id: n.id, + path: n.path, + kind: n.kind, + group: n.group || (n.path.split("/").length > 1 ? n.path.split("/")[0] : "(root)"), + })), + focus: s.codemapFocus, + touches: s.codemapTouches, + }; + const ist = s.interviewSteps; + const interviewSteps = ist + ? { label: ist.label, steps: ist.names.map((name, i) => { + const status = (i < ist.current ? "done" : i === ist.current ? "active" : "pending") as "done" | "active" | "pending"; + return status === "active" && ist.progress ? { name, status, progress: ist.progress } : { name, status }; + }) } + : null; + return { + // A directly-launched review/interview/backlog sets domain without session.begin + // (so task is "" and phase null); treat any active domain as started so the Home + // orient strip never claims "Nothing running" mid-session. (B1) + started: s.task !== "" || s.phase !== null || s.domain !== null, + task: s.task, + host: s.host, + domain: s.domain, + reviewTarget: s.reviewTarget, + findingGroups, + board, + team, + codemap, + dataProfile: { dataset: s.profileDataset, columns: s.profileColumns }, + gallery: { + title: s.galleryTitle, + size: s.gallerySize, + items: s.galleryItems.map((it) => ({ + id: it.id, + label: it.label, + group: it.group ?? null, + kind: it.url ? "url" : it.html ? "html" : "empty", + src: it.url ?? it.html ?? null, + caption: it.caption ?? null, + })), + }, + promptlab: { + name: s.promptName, + round: s.promptRound, + prompt: s.promptArtifact, + summary: s.promptCases.reduce( + (a, c) => { a[c.verdict]++; a.total++; return a; }, + { pass: 0, warn: 0, fail: 0, pending: 0, running: 0, total: 0 }, + ), + cases: s.promptCases.map((c) => ({ id: c.id, scenario: c.scenario, output: c.output ?? null, verdict: c.verdict, note: c.note ?? null })), + }, + memory: { + title: s.memoryTitle, + counts: s.memoryEntries.reduce( + (a, e) => { a[e.tag]++; a.total++; return a; }, + { recalled: 0, added: 0, updated: 0, total: 0 }, + ), + entries: s.memoryEntries.map((e) => ({ id: e.id, title: e.title ?? null, content: e.content, category: e.category, tag: e.tag })), + handoffs: s.memoryHandoffs.map((h) => ({ id: h.id, from: h.from, summary: h.summary, action: h.action })), + }, + flow: { + title: s.flowTitle, + focus: s.flowFocus, + nodes: s.flowNodes.map((n) => ({ id: n.id, scope: n.scope, kind: n.kind, label: n.label, sub: n.sub ?? null, status: n.status })), + edges: s.flowEdges.map((e) => ({ id: e.id, from: e.from, to: e.to, scope: e.scope, label: e.label ?? null, kind: e.kind, status: e.status })), + }, + view: s.view, + navigatorOpen: s.navigatorOpen, + workflows: WORKFLOWS.map((w) => ({ id: w.id, name: w.name, hint: w.hint, description: w.description })), + activeWorkflow: s.activeWorkflow, + phase: s.phase, + phaseLabel: s.phase ? LABEL[s.phase] : null, + phaseNumber: s.phase ? idx + 1 : null, + lead: s.phase ? LEAD[s.phase] : EMPTY_LEAD, + steps, + subagents: s.subagents.map((a) => ({ name: a.name, status: a.status, role: a.role })), + validations: Object.entries(s.validations).map(([check, status]) => ({ check, status })), + docType: s.docType, + interviewSteps, + decisions: s.decisions.map((d) => ({ id: d.id, prompt: d.prompt, kind: d.kind, options: d.options, answer: d.answer, status: d.status })), + hostElicits: s.hostElicits, + steerMenu, + directives: s.directives, + screen: s.screen, + context: { instructions: s.contextInstructions, skills: s.contextSkills, collection: s.contextCollection }, + appFrame: { url: s.appFrameUrl }, + log: s.log, + }; +} diff --git a/rpi-cockpit/src/server.ts b/rpi-cockpit/src/server.ts new file mode 100644 index 000000000..385ea8221 --- /dev/null +++ b/rpi-cockpit/src/server.ts @@ -0,0 +1,232 @@ +// rpi-cockpit/src/server.ts +import http from "node:http"; +import crypto from "node:crypto"; +import os from "node:os"; +import { readFile, mkdir, appendFile } from "node:fs/promises"; +import { writeFileSync, renameSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { WebSocketServer, type WebSocket } from "ws"; +import type { Bridge } from "./bridge.js"; +import type { SessionState } from "./state.js"; +import { toViewModel } from "./render.js"; +import type { Directive } from "./events.js"; +import { parseInbound, applyInbound, type InboundFrame } from "./inbound.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const PUBLIC = path.join(here, "..", "public"); +const TYPES: Record = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css" }; + +// Constant-time token comparison. Guard length first: timingSafeEqual throws on +// unequal-length buffers, and an early length check is itself not secret-leaking. +function tokenMatches(candidate: string | null | undefined, token: string): boolean { + if (typeof candidate !== "string") return false; + const a = Buffer.from(candidate); + const b = Buffer.from(token); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); +} + +// Pull a value from a Cookie header. Cookies are name=value pairs joined by "; ". +function readCookie(cookieHeader: string | undefined, name: string): string | null { + if (!cookieHeader) return null; + for (const part of cookieHeader.split(";")) { + const eq = part.indexOf("="); + if (eq === -1) continue; + if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim(); + } + return null; +} + +export async function startServer( + bridge: Bridge, + port = 4399, + opts?: { + stateDir?: string; + trustLoopback?: boolean; + // Producer-only: when true, atomically rewrite /state.json on every + // bridge "state" event so the live consumer process can mirror it. Off by + // default; existing callers and tests are unchanged. + writeStateSnapshot?: boolean; + // Consumer-only: when provided, recognized inbound WS frames are handed to + // this callback (the consumer routes them to inbox.jsonl) instead of driving + // the local bridge. Off by default; the WS handler drives the bridge as before. + onInbound?: (f: InboundFrame) => void; + }, +) { + // Embed mode: when a trusted host (the Claude Preview pane or a VS Code + // webview) launches and owns this server, it loads the bare root URL, which + // the token gate would 403. Opting into trustLoopback skips the token check on + // both the HTTP gate and the WS upgrade so the pane loads with no key. The + // Origin check below is KEPT in both modes, so a browser still cannot drive a + // cross-origin connection. Trade-off: embed mode trusts ANY loopback client, + // which is the host-managed-pane trust boundary — anyone who can already reach + // 127.0.0.1 on this port is treated as the user. Secure default is unchanged: + // with the flag off, the per-session token is still required. + const trustLoopback = opts?.trustLoopback ?? Boolean(process.env.RPI_COCKPIT_TRUST_LOOPBACK); + // Per-session token: minted in-memory each run, never persisted. Fail closed — + // it is always present, so every HTTP request and WS upgrade is gated. + const token = crypto.randomBytes(32).toString("hex"); + // Cookie name is port-scoped so multiple cockpits on one host don't collide. + let cookieName = `rpi-cockpit-key-${port}`; + + // Authorized if the request carries the token as ?key= OR as the cookie. + const isAuthorized = (url: string | undefined, cookieHeader: string | undefined): boolean => { + const keyFromQuery = url ? new URL(url, "http://localhost").searchParams.get("key") : null; + if (tokenMatches(keyFromQuery, token)) return true; + return tokenMatches(readCookie(cookieHeader, cookieName), token); + }; + + const httpServer = http.createServer(async (req, res) => { + // HTTP gate: before any file serving, require a valid token (query or cookie), + // UNLESS embed mode trusts the loopback pane, in which case the bare root and + // assets are served without a key. + if (!trustLoopback && !isAuthorized(req.url, req.headers.cookie)) { + res.writeHead(403, { "content-type": "text/html" }); + res.end("RPI Cockpit" + + "

Unauthorized. Open the full cockpit URL including ?key=… printed on startup.

"); + return; + } + + // On a valid ?key=, mirror the token into a hardened cookie so subsequent + // asset and WS requests authenticate without the query string. + const keyFromQuery = req.url ? new URL(req.url, "http://localhost").searchParams.get("key") : null; + if (tokenMatches(keyFromQuery, token)) { + res.setHeader("Set-Cookie", `${cookieName}=${token}; HttpOnly; SameSite=Strict; Path=/`); + } + + // Strip the query first, then map the bare root to index.html. (A ?key= on + // "/" means req.url is "/?key=…", which is not literally "/".) + const pathname = (req.url ?? "/").split("?")[0]; + const rel = pathname === "/" ? "/index.html" : pathname; + const abs = path.join(PUBLIC, rel); + if (abs !== PUBLIC && !abs.startsWith(PUBLIC + path.sep)) { + res.writeHead(403); res.end("forbidden"); return; + } + try { + const file = await readFile(abs); + res.writeHead(200, { "content-type": TYPES[path.extname(abs)] ?? "application/octet-stream" }); + res.end(file); + } catch { + res.writeHead(404); res.end("not found"); + } + }); + + // WS gate: reject unauthorized upgrades BEFORE the handshake completes. + // Authorized = valid token (query or cookie) AND an acceptable Origin. The + // Origin check defeats cross-origin-localhost / DNS-rebinding from a browser: + // a non-browser client (e.g. tests) sends no Origin and is allowed; a browser + // must present Origin exactly matching this server's own host. + const wss = new WebSocketServer({ + server: httpServer, + verifyClient: (info, cb) => { + // In embed mode the token is waived, but the Origin check is NOT: a browser + // still must present an Origin matching this server's own host (or none, as + // non-browser clients do), which defeats cross-origin / DNS-rebinding. + const tokenOk = trustLoopback || isAuthorized(info.req.url, info.req.headers.cookie); + const origin = info.req.headers.origin; + const originOk = !origin || origin === "http://" + info.req.headers.host; + if (tokenOk && originOk) cb(true); + else cb(false, 401); + }, + }); + const send = (ws: WebSocket, state: SessionState) => ws.send(JSON.stringify({ type: "state", state, view: toViewModel(state) })); + wss.on("connection", (ws) => { + send(ws, bridge.state); + ws.on("message", (data) => { + let msg: unknown; + try { msg = JSON.parse(String(data)); } catch { return; } + // Validate once, in one place (shared with the producer's inbox tailer). + const f = parseInbound(msg); + if (!f) return; + // Consumer mode routes the frame elsewhere (to inbox.jsonl); otherwise the + // frame drives this server's bridge exactly as the inline chain used to. + if (opts?.onInbound) opts.onInbound(f); + else applyInbound(bridge, f); + }); + }); + const broadcast = (state: SessionState) => { for (const c of wss.clients) if (c.readyState === 1) send(c, state); }; + bridge.on("state", broadcast); + + // ws re-emits the underlying http server's "listening" and "error" events on + // the WebSocketServer, so bind outcomes must be observed on wss to be caught. + const listen = (p: number) => + new Promise((resolve, reject) => { + const onError = (err: NodeJS.ErrnoException) => { wss.off("listening", onListening); reject(err); }; + const onListening = () => { wss.off("error", onError); resolve(); }; + wss.once("error", onError); + wss.once("listening", onListening); + httpServer.listen(p, "127.0.0.1"); + }); + + try { + await listen(port); + } catch (err) { + if (port !== 0 && (err as NodeJS.ErrnoException).code === "EADDRINUSE") { + await listen(0); // requested port taken; fall back to an ephemeral one + } else { + throw err; + } + } + const addr = httpServer.address(); + if (!addr || typeof addr === "string") throw new Error("server did not bind a TCP port"); + // The bound port may differ from the requested one (ephemeral fallback); scope + // the cookie name to whatever port we actually bound so it always matches. + cookieName = `rpi-cockpit-key-${addr.port}`; + const url = "http://127.0.0.1:" + addr.port + "/?key=" + token; + + // File sink: durable, append-only record the agent can read WITHOUT the MCP + // server connected. Resolve against the bound port so the default is stable. + const stateDir = opts?.stateDir + ?? process.env.RPI_COCKPIT_STATE_DIR + ?? path.join(os.tmpdir(), "rpi-cockpit", String(addr.port)); + // Best-effort create; if it fails the appends below just no-op on their own. + await mkdir(stateDir, { recursive: true }).catch(() => {}); + // Append one JSON record per line. Never let an fs error escape into the + // event path — swallow and continue so narration is unaffected. + const append = (file: string, record: unknown): void => { + appendFile(path.join(stateDir, file), JSON.stringify(record) + "\n").catch(() => {}); + }; + const onDirective = (d: Directive) => append("directives.jsonl", { ...d, ts: Date.now() }); + const onDecision = (x: { id: string; choiceId: string; prompt?: string }) => + append("decisions.jsonl", { ...x, ts: Date.now() }); + bridge.on("directive", onDirective); + bridge.on("decision", onDecision); + + // Producer snapshot: atomically rewrite state.json on every state change so the + // live consumer process always reads a complete file. Write to a temp path then + // rename (rename is atomic on the same filesystem). Synchronous so concurrent + // state events cannot interleave a half-written file. Off by default. + let onSnapshot: ((state: SessionState) => void) | null = null; + if (opts?.writeStateSnapshot) { + const statePath = path.join(stateDir, "state.json"); + const tmpPath = path.join(stateDir, "state.json.tmp"); + onSnapshot = (state: SessionState) => { + try { + writeFileSync(tmpPath, JSON.stringify(state)); + renameSync(tmpPath, statePath); + } catch { /* never throw into the event path */ } + }; + bridge.on("state", onSnapshot); + // Seed the file with the current state so a consumer that connects before the + // first beat still has a snapshot to render. + onSnapshot(bridge.state); + } + + return { + port: addr.port, + token, + url, + stateDir, + close: () => new Promise((resolve, reject) => { + bridge.off("state", broadcast); + bridge.off("directive", onDirective); + bridge.off("decision", onDecision); + if (onSnapshot) bridge.off("state", onSnapshot); + wss.close((err) => { + if (err) return reject(err); + httpServer.close((e) => (e ? reject(e) : resolve())); + }); + }), + }; +} diff --git a/rpi-cockpit/src/state.ts b/rpi-cockpit/src/state.ts new file mode 100644 index 000000000..8658b3cfc --- /dev/null +++ b/rpi-cockpit/src/state.ts @@ -0,0 +1,320 @@ +// rpi-cockpit/src/state.ts +import type { Beat, Phase, OptionItem, ValidationStatus, Directive, Finding, AgentStatus, CodeKind } from "./events.js"; + +export interface Subagent { name: string; role?: string; status: "active" | "idle"; result?: string; } +export interface TeamAgent { id: string; name: string; role?: string; status: AgentStatus; action?: string | null; } +export interface BacklogItem { id: string; title: string; column: string; kind?: string; tier?: string; parent?: string; } +export interface ProfileColumn { name: string; dtype: string; nullPct?: number; distinct?: number; stat?: string; quality?: "ok" | "warn" | "risk"; } +export interface CodeNode { id: string; path: string; kind: CodeKind; group?: string; } +export interface Decision { id: string; prompt: string; options: OptionItem[]; } +export type DecisionKind = "choice" | "text"; +export type DecisionStatus = "pending" | "answered" | "superseded"; +export interface DecisionEntry { id: string; prompt: string; kind: DecisionKind; options?: OptionItem[]; answer?: string; status: DecisionStatus; } +export interface GalleryItem { id: string; label: string; group?: string; url?: string; html?: string; caption?: string; } +export type PromptVerdict = "pending" | "running" | "pass" | "warn" | "fail"; +export interface PromptCase { id: string; scenario: string; output?: string; verdict: PromptVerdict; note?: string; } +export type MemoryTag = "recalled" | "added" | "updated"; +export interface MemoryEntry { id: string; title?: string; content: string; category: string; tag: MemoryTag; } +export type HandoffAction = "stored" | "merged" | "recalled"; +export interface MemoryHandoff { id: string; from: string; summary: string; action: HandoffAction; } +export type FlowNodeKind = "workflow" | "trigger" | "guard" | "agent" | "output" | "mcp"; +export type FlowStatus = "idle" | "running" | "passed" | "failed" | "skipped" | "stale"; +export interface FlowNode { id: string; scope: string; kind: FlowNodeKind; label: string; sub?: string; status: FlowStatus; } +export type FlowEdgeKind = "label" | "event" | "output" | "step"; +export type FlowEdgeStatus = "idle" | "active"; +export interface FlowEdge { id: string; from: string; to: string; scope: string; label?: string; kind: FlowEdgeKind; status: FlowEdgeStatus; } +export interface LogEntry { t: number; kind: string; detail: string; } +export interface SteerMenu { label: string; options: OptionItem[]; } + +export interface SessionState { + task: string; + host: string; + domain: "rpi" | "review" | "interview" | "backlog" | "team" | "codemap" | "dataprofile" | "gallery" | "promptlab" | "memory" | "flow" | null; + reviewTarget: string | null; + orchestrator: string | null; + teamAgents: TeamAgent[]; + findings: Finding[]; + boardTarget: string | null; + boardColumns: string[]; + boardItems: BacklogItem[]; + boardAction: string | null; + view: "home" | "loop"; + navigatorOpen: boolean; + activeWorkflow: string | null; + phase: Phase | null; + phasesDone: Phase[]; + subagents: Subagent[]; + validations: Record; + artifacts: { path: string; summary?: string }[]; + docType: string | null; + interviewSteps: { label?: string; names: string[]; current: number; progress?: { done: number; total: number } } | null; + decisions: DecisionEntry[]; + hostElicits: boolean; + directives: Directive[]; + steerMenu: SteerMenu | null; + // Single shared screen slot: the RPI screen pane (#screen) and the interview + // document pane (#iv-doc) both read this one field. A screen.show or clear_screen + // is not domain-scoped, so the agent should clear_screen when switching contexts + // (e.g. leaving an interview) or a stale document leaks into the next pane. (B6/M1) + screen: { html: string; title?: string } | null; + contextInstructions: string[]; + contextSkills: string[]; + contextCollection: string | null; + appFrameUrl: string | null; + codemapNodes: CodeNode[]; + codemapFocus: string | null; + codemapTouches: Record; + profileDataset: { name: string; rows?: number; cols?: number; source?: string } | null; + profileColumns: ProfileColumn[]; + galleryTitle: string | null; + gallerySize: "s" | "m" | "l"; + galleryItems: GalleryItem[]; + promptName: string | null; + promptRound: number; + promptArtifact: string | null; + promptCases: PromptCase[]; + memoryTitle: string | null; + memoryEntries: MemoryEntry[]; + memoryHandoffs: MemoryHandoff[]; + flowTitle: string | null; + flowNodes: FlowNode[]; + flowEdges: FlowEdge[]; + flowFocus: string | null; + log: LogEntry[]; +} + +export function initialState(): SessionState { + return { task: "", host: "", domain: null, reviewTarget: null, orchestrator: null, teamAgents: [], findings: [], boardTarget: null, boardColumns: [], boardItems: [], boardAction: null, view: "home", navigatorOpen: false, activeWorkflow: null, phase: null, phasesDone: [], subagents: [], validations: {}, artifacts: [], docType: null, interviewSteps: null, decisions: [], hostElicits: false, directives: [], steerMenu: null, screen: null, contextInstructions: [], contextSkills: [], contextCollection: null, appFrameUrl: null, codemapNodes: [], codemapFocus: null, codemapTouches: {}, profileDataset: null, profileColumns: [], galleryTitle: null, gallerySize: "m", galleryItems: [], promptName: null, promptRound: 1, promptArtifact: null, promptCases: [], memoryTitle: null, memoryEntries: [], memoryHandoffs: [], flowTitle: null, flowNodes: [], flowEdges: [], flowFocus: null, log: [] }; +} + +function normGalleryItem(it: { id?: string; label: string; group?: string; url?: string; html?: string; caption?: string }, idx: number): GalleryItem { + return { id: it.id && it.id.length ? it.id : `g${idx}`, label: it.label, group: it.group, url: it.url, html: it.html, caption: it.caption }; +} + +export function applyBeat(s: SessionState, beat: Beat, now: number): SessionState { + const log = [...s.log, { t: now, kind: beat.type, detail: summarize(beat) }]; + switch (beat.type) { + case "session.begin": + return { ...s, task: beat.task, host: beat.host, domain: "rpi", view: "loop", log }; + case "phase.enter": { + const phasesDone = s.phase && s.phase !== beat.phase && !s.phasesDone.includes(s.phase) + ? [...s.phasesDone, s.phase] : s.phasesDone; + return { ...s, phase: beat.phase, phasesDone, steerMenu: null, log }; + } + case "subagent.start": { + const others = s.subagents.filter((a) => a.name !== beat.name); + return { ...s, subagents: [...others, { name: beat.name, role: beat.role, status: "active" }], log }; + } + case "subagent.stop": + return { ...s, subagents: s.subagents.map((a) => a.name === beat.name ? { ...a, status: "idle", result: beat.result } : a), log }; + case "artifact.update": { + const others = s.artifacts.filter((x) => x.path !== beat.path); + return { ...s, artifacts: [...others, { path: beat.path, summary: beat.summary }], log }; + } + case "validate": + return { ...s, validations: { ...s.validations, [beat.check]: beat.status }, log }; + case "approaches.offer": + return { ...s, steerMenu: { label: beat.label, options: beat.options }, log }; + case "screen.show": + return { ...s, screen: { html: beat.html, title: beat.title }, log }; + case "screen.clear": + return { ...s, screen: null, log }; + case "review.start": + return { ...s, view: "loop", domain: "review", reviewTarget: beat.target, findings: [], log }; + case "finding.add": + return { ...s, findings: [...s.findings, { severity: beat.severity, title: beat.title, file: beat.file, line: beat.line, detail: beat.detail }], log }; + case "interview.start": + return { ...s, view: "loop", domain: "interview", docType: beat.docType, interviewSteps: null, log }; + case "steps.set": { + const current = Math.max(0, Math.min(beat.current, beat.steps.length - 1)); + return { ...s, interviewSteps: { label: beat.label, names: beat.steps, current, progress: beat.progress }, log }; + } + case "backlog.start": + return { ...s, view: "loop", domain: "backlog", boardTarget: beat.target, boardColumns: beat.columns, boardItems: [], boardAction: null, log }; + case "item.add": { + const others = s.boardItems.filter((i) => i.id !== beat.id); + return { ...s, boardItems: [...others, { id: beat.id, title: beat.title, column: beat.column, kind: beat.kind, tier: beat.tier, parent: beat.parent }], log }; + } + case "item.move": + return { ...s, boardItems: s.boardItems.map((i) => i.id === beat.id ? { ...i, column: beat.column } : i), log }; + case "backlog.action": + return { ...s, boardAction: beat.text, log }; + case "profile.start": + return { ...s, view: "loop", domain: "dataprofile", profileDataset: { name: beat.name, rows: beat.rows, cols: beat.columns, source: beat.source }, profileColumns: [], log }; + case "column.add": { + const col = { name: beat.name, dtype: beat.dtype, nullPct: beat.nullPct, distinct: beat.distinct, stat: beat.stat, quality: beat.quality }; + const exists = s.profileColumns.some((c) => c.name === beat.name); + return { ...s, profileColumns: exists ? s.profileColumns.map((c) => (c.name === beat.name ? col : c)) : [...s.profileColumns, col], log }; + } + case "gallery.open": + return { ...s, view: "loop", domain: "gallery", galleryTitle: beat.title, gallerySize: beat.size ?? "m", galleryItems: beat.items.map((it, i) => normGalleryItem(it, i)), log }; + case "gallery.add": { + const item = normGalleryItem(beat.item, s.galleryItems.length); + const exists = s.galleryItems.some((g) => g.id === item.id); + return { ...s, view: "loop", domain: "gallery", galleryItems: exists ? s.galleryItems.map((g) => (g.id === item.id ? item : g)) : [...s.galleryItems, item], log }; + } + case "gallery.clear": + return { ...s, galleryItems: [], log }; + case "promptlab.start": + return { ...s, view: "loop", domain: "promptlab", promptName: beat.name, promptArtifact: beat.prompt ?? null, promptRound: beat.round ?? 1, promptCases: [], log }; + case "case.add": { + const c = { id: beat.id, scenario: beat.scenario, output: beat.output, verdict: beat.verdict ?? "pending", note: beat.note }; + const exists = s.promptCases.some((x) => x.id === beat.id); + return { ...s, promptCases: exists ? s.promptCases.map((x) => (x.id === beat.id ? c : x)) : [...s.promptCases, c], log }; + } + case "memory.open": + return { ...s, view: "loop", domain: "memory", memoryTitle: beat.title ?? null, memoryEntries: [], memoryHandoffs: [], log }; + case "memory.add": { + const e = { id: beat.id, title: beat.title, content: beat.content, category: beat.category, tag: beat.tag ?? "recalled" }; + const exists = s.memoryEntries.some((x) => x.id === beat.id); + return { ...s, memoryEntries: exists ? s.memoryEntries.map((x) => (x.id === beat.id ? e : x)) : [...s.memoryEntries, e], log }; + } + case "handoff.add": { + const h = { id: beat.id, from: beat.from, summary: beat.summary, action: beat.action ?? "stored" }; + const exists = s.memoryHandoffs.some((x) => x.id === beat.id); + return { ...s, memoryHandoffs: exists ? s.memoryHandoffs.map((x) => (x.id === beat.id ? h : x)) : [...s.memoryHandoffs, h], log }; + } + case "flow.open": + return { ...s, view: "loop", domain: "flow", flowTitle: beat.title ?? null, flowNodes: [], flowEdges: [], flowFocus: null, log }; + case "flownode.add": { + const n = { id: beat.id, scope: beat.scope ?? "orchestration", kind: beat.kind, label: beat.label, sub: beat.sub, status: beat.status ?? "idle" }; + const exists = s.flowNodes.some((x) => x.id === beat.id); + return { ...s, flowNodes: exists ? s.flowNodes.map((x) => (x.id === beat.id ? n : x)) : [...s.flowNodes, n], log }; + } + case "flowedge.add": { + const e = { id: beat.id, from: beat.from, to: beat.to, scope: beat.scope ?? "orchestration", label: beat.label, kind: beat.kind ?? "label", status: beat.status ?? "idle" }; + const exists = s.flowEdges.some((x) => x.id === beat.id); + return { ...s, flowEdges: exists ? s.flowEdges.map((x) => (x.id === beat.id ? e : x)) : [...s.flowEdges, e], log }; + } + case "flow.focus": + return { ...s, flowFocus: beat.workflow ?? null, log }; + case "context.set": + return { ...s, contextInstructions: beat.instructions, contextSkills: beat.skills, contextCollection: beat.collection, log }; + case "appframe.set": + return { ...s, appFrameUrl: beat.url, log }; + case "team.start": + return { ...s, view: "loop", domain: "team", task: beat.task, orchestrator: beat.orchestrator, teamAgents: [], log }; + case "agent.add": + return { ...s, teamAgents: [...s.teamAgents.filter((x) => x.id !== beat.id), { id: beat.id, name: beat.name, role: beat.role, status: beat.status }], log }; + case "agent.update": + return { ...s, teamAgents: s.teamAgents.map((a) => a.id === beat.id ? { ...a, ...(beat.status !== undefined ? { status: beat.status } : {}), ...(beat.action !== undefined ? { action: beat.action } : {}) } : a), log }; + case "agent.remove": + return { ...s, teamAgents: s.teamAgents.filter((a) => a.id !== beat.id), log }; + case "codemap.set": + return { ...s, view: "loop", domain: "codemap", codemapNodes: beat.nodes, codemapFocus: null, codemapTouches: {}, log }; + case "codemap.focus": { + const exists = s.codemapNodes.some((n) => n.id === beat.id); + return exists ? { ...s, codemapFocus: beat.id, log } : { ...s, log }; + } + case "codemap.touch": { + const exists = s.codemapNodes.some((n) => n.id === beat.id); + if (!exists) return { ...s, log }; + const cur = s.codemapTouches[beat.id]; + const next = (cur === "edit" || beat.kind === "edit") ? "edit" : "read"; + return { ...s, codemapTouches: { ...s.codemapTouches, [beat.id]: next }, log }; + } + } +} + +function summarize(beat: Beat): string { + switch (beat.type) { + case "session.begin": return beat.task; + case "phase.enter": return beat.phase; + case "subagent.start": return beat.name; + case "subagent.stop": return `${beat.name}: ${beat.result ?? "done"}`; + case "artifact.update": return `${beat.path} ${beat.summary ?? ""}`.trim(); + case "validate": return `${beat.check}=${beat.status}`; + case "approaches.offer": return beat.label; + case "screen.show": return beat.title ?? "screen"; + case "screen.clear": return "cleared"; + case "review.start": return `review ${beat.target}`; + case "finding.add": return `${beat.severity}: ${beat.title}`; + case "interview.start": return `interview ${beat.docType}`; + case "steps.set": return beat.label ?? "steps"; + case "backlog.start": return `backlog ${beat.target}`; + case "item.add": return `${beat.id}: ${beat.title}`; + case "item.move": return `${beat.id} -> ${beat.column}`; + case "backlog.action": return beat.text ?? "(cleared)"; + case "profile.start": return beat.name; + case "column.add": return beat.name; + case "context.set": return `${beat.instructions.length} instr · ${beat.skills.length} skills${beat.collection ? " · " + beat.collection : ""}`; + case "appframe.set": return beat.url ? `app frame ${beat.url}` : "app frame cleared"; + case "team.start": return `team ${beat.orchestrator}`; + case "agent.add": return `${beat.name} (${beat.status})`; + case "agent.update": return `${beat.id}${beat.status ? " " + beat.status : ""}`; + case "agent.remove": return beat.id; + case "codemap.set": return `${beat.nodes.length} nodes`; + case "codemap.focus": return beat.id; + case "codemap.touch": return `${beat.kind} ${beat.id}`; + case "gallery.open": return beat.title; + case "gallery.add": return beat.item.label; + case "gallery.clear": return "cleared"; + case "promptlab.start": return beat.name; + case "case.add": return beat.scenario; + case "memory.open": return beat.title ?? "memory"; + case "memory.add": return beat.id; + case "handoff.add": return beat.from; + case "flow.open": return beat.title ?? "flow"; + case "flownode.add": return beat.label; + case "flowedge.add": return beat.id; + case "flow.focus": return beat.workflow ?? "(orchestration)"; + } +} + +export function addDecision(s: SessionState, e: { id: string; prompt: string; kind: DecisionKind; options?: OptionItem[] }): SessionState { + const idx = s.decisions.findIndex((d) => d.id === e.id); + const entry: DecisionEntry = { id: e.id, prompt: e.prompt, kind: e.kind, options: e.options, status: "pending" }; + if (idx !== -1) return { ...s, decisions: s.decisions.map((d, j) => (j === idx ? entry : d)) }; + return { ...s, decisions: [...s.decisions, entry] }; +} + +export function answerDecision(s: SessionState, id: string, answer: string): SessionState { + return { ...s, decisions: s.decisions.map((d) => (d.id === id ? { ...d, answer, status: "answered" } : d)) }; +} + +export function reviseDecision(s: SessionState, id: string): SessionState { + const idx = s.decisions.findIndex((d) => d.id === id); + if (idx === -1) return s; + return { + ...s, + decisions: s.decisions.map((d, j) => { + if (j === idx) return { ...d, answer: undefined, status: "pending" }; + if (j > idx && d.status === "answered") return { ...d, status: "superseded" }; + return d; + }), + }; +} + +export function setHostElicits(s: SessionState, v: boolean): SessionState { + return { ...s, hostElicits: v }; +} + +export function enqueueDirective(s: SessionState, directive: Directive, now: number): SessionState { + return { + ...s, + directives: [...s.directives, directive], + log: [...s.log, { t: now, kind: "directive.queued", detail: summarizeDirective(directive) }], + }; +} + +export function drainDirectives(s: SessionState, now: number): { state: SessionState; drained: Directive[] } { + if (s.directives.length === 0) return { state: s, drained: [] }; + const log = [...s.log, ...s.directives.map((d) => ({ t: now, kind: "directive.consumed", detail: summarizeDirective(d) }))]; + return { state: { ...s, directives: [], log }, drained: s.directives }; +} + +function summarizeDirective(d: Directive): string { + return d.kind === "note" ? `note: ${d.text}` : `approach: ${d.label}`; +} + +export function setView(s: SessionState, view: "home" | "loop"): SessionState { + return { ...s, view }; +} + +export function startLaunch(s: SessionState, workflowId: string): SessionState { + return { ...s, view: "loop", navigatorOpen: false, activeWorkflow: workflowId }; +} + +export function setNavigatorOpen(s: SessionState, open: boolean): SessionState { + return { ...s, navigatorOpen: open }; +} diff --git a/rpi-cockpit/src/url.ts b/rpi-cockpit/src/url.ts new file mode 100644 index 000000000..6a0367e49 --- /dev/null +++ b/rpi-cockpit/src/url.ts @@ -0,0 +1,32 @@ +// rpi-cockpit/src/url.ts +// Shared loopback predicate. The app frame embeds a TRUSTED iframe of the user's +// app-under-development (scripts + the app's own origin), so the URL it loads must +// be constrained to a loopback http(s) origin. This single predicate is the one +// source of truth: the MCP tool boundary enforces it server-side, and the client +// mirrors the same logic before assigning the iframe src (defense in depth). The +// client copy in public/client.js must stay byte-for-byte equivalent to this. +export function isLoopbackHttpUrl(u: string): boolean { + try { + const url = new URL(u); + if (url.protocol !== "http:" && url.protocol !== "https:") return false; + const h = url.hostname.toLowerCase(); + return h === "localhost" || h === "127.0.0.1" || h === "[::1]" || h === "::1"; + } catch { + return false; + } +} + +// Gallery tiles may frame loopback dev servers (http or https) and external +// https sites. External http is rejected. The client mirrors this predicate +// before assigning an iframe src (defense in depth), so keep the two copies +// byte-for-byte equivalent (the copy lives in public/client.js). +export function isGalleryUrl(u: string): boolean { + try { + const url = new URL(u); + if (url.protocol !== "http:" && url.protocol !== "https:") return false; + if (isLoopbackHttpUrl(u)) return true; + return url.protocol === "https:"; + } catch { + return false; + } +} diff --git a/rpi-cockpit/tests/appframe-client.test.ts b/rpi-cockpit/tests/appframe-client.test.ts new file mode 100644 index 000000000..c94d7d645 --- /dev/null +++ b/rpi-cockpit/tests/appframe-client.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Window } from "happy-dom"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { initialState, applyBeat } from "../src/state.js"; +import { toViewModel } from "../src/render.js"; + +const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "public"); + +function boot() { + const html = readFileSync(path.join(PUBLIC, "index.html"), "utf8"); + const js = readFileSync(path.join(PUBLIC, "client.js"), "utf8"); + const win = new Window({ url: "http://127.0.0.1:4399/" }); + win.document.write(html); + (win as any).WebSocket = class { readyState = 1; send() {} close() {} }; + win.eval(js.replace(/^import .*$/gm, "")); + return win; +} + +function appFrameVm(url: string | null) { + const s = applyBeat(initialState(), { type: "appframe.set", url }, 1); + return toViewModel(s); +} + +describe("app frame client", () => { + let win: ReturnType; + beforeEach(() => { win = boot(); }); + + it("shows the panel, sets the iframe src, and shows the url for a loopback URL", () => { + (win as any).render(appFrameVm("http://localhost:5173")); + const panel = win.document.getElementById("app-frame") as any; + const iframe = win.document.getElementById("af-iframe") as any; + expect(panel.hidden).toBe(false); + expect(iframe.getAttribute("src")).toBe("http://localhost:5173"); + expect((win.document.getElementById("af-url") as any).textContent).toBe("http://localhost:5173"); + }); + + it("hides the panel and clears the iframe src when the url is null", () => { + (win as any).render(appFrameVm("http://localhost:5173")); + (win as any).render(appFrameVm(null)); + const panel = win.document.getElementById("app-frame") as any; + const iframe = win.document.getElementById("af-iframe") as any; + expect(panel.hidden).toBe(true); + expect(iframe.getAttribute("src")).toBeNull(); + }); + + it("keeps the panel hidden and sets no src for a NON-loopback URL (client guard)", () => { + (win as any).render(appFrameVm("http://evil.com")); + const panel = win.document.getElementById("app-frame") as any; + const iframe = win.document.getElementById("af-iframe") as any; + expect(panel.hidden).toBe(true); + expect(iframe.getAttribute("src")).toBeNull(); + }); + + it("locks the iframe sandbox to exactly the bounded value", () => { + const iframe = win.document.getElementById("af-iframe") as any; + expect(iframe.getAttribute("sandbox")).toBe("allow-scripts allow-same-origin allow-forms"); + }); +}); diff --git a/rpi-cockpit/tests/backlog-client.test.ts b/rpi-cockpit/tests/backlog-client.test.ts new file mode 100644 index 000000000..e25bcceb2 --- /dev/null +++ b/rpi-cockpit/tests/backlog-client.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Window } from "happy-dom"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { initialState, applyBeat } from "../src/state.js"; +import { toViewModel } from "../src/render.js"; + +const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "public"); + +function boot() { + const html = readFileSync(path.join(PUBLIC, "index.html"), "utf8"); + const js = readFileSync(path.join(PUBLIC, "client.js"), "utf8"); + const win = new Window({ url: "http://127.0.0.1:4399/" }); + win.document.write(html); + (win as any).WebSocket = class { readyState = 1; send() {} close() {} }; + win.eval(js.replace(/^import .*$/gm, "")); + return win; +} + +function boardVm(action: string | null = null) { + let s = applyBeat(initialState(), { type: "backlog.start", target: "Sprint 4", columns: ["Todo", "Doing", "Done"] }, 1); + s = applyBeat(s, { type: "item.add", id: "I1", title: "fix login", column: "Todo", kind: "bug", tier: "T1" }, 2); + s = applyBeat(s, { type: "item.add", id: "I2", title: "ship it", column: "Done" }, 3); + s = applyBeat(s, { type: "item.add", id: "I3", title: "second todo", column: "Todo" }, 4); + if (action !== null) s = applyBeat(s, { type: "backlog.action", text: action }, 5); + return toViewModel(s); +} + +function hierarchyVm() { + let s = applyBeat(initialState(), { type: "backlog.start", target: "S", columns: ["Plan", "Done"] }, 1); + s = applyBeat(s, { type: "item.add", id: "E", title: "Epic", column: "Plan" }, 2); + s = applyBeat(s, { type: "item.add", id: "F", title: "Feature", column: "Plan", parent: "E" }, 3); + s = applyBeat(s, { type: "item.add", id: "S", title: "Story", column: "Done", parent: "E" }, 4); + return toViewModel(s); +} + +describe("backlog client", () => { + let win: ReturnType; + beforeEach(() => { win = boot(); }); + + it("paints one .board-col per column with the right card count and ids", () => { + (win as any).render(boardVm()); + const cols = win.document.querySelectorAll("#board .board-col"); + expect(cols.length).toBe(3); + const firstCards = cols[0].querySelectorAll(".bcard"); + expect(firstCards.length).toBe(2); + const ids = Array.from(firstCards).map((c: any) => c.querySelector(".bcard-id")!.textContent); + expect(ids).toEqual(["I1", "I3"]); + // The empty Doing column keeps rendering with no cards. + expect(cols[1].querySelectorAll(".bcard").length).toBe(0); + expect(cols[2].querySelectorAll(".bcard").length).toBe(1); + }); + + it("shows backlog-view and hides the other loop views on the backlog domain", () => { + (win as any).render(boardVm()); + expect((win.document.getElementById("backlog-view") as any).hidden).toBe(false); + expect((win.document.getElementById("rpi-view") as any).hidden).toBe(true); + expect((win.document.getElementById("findings-view") as any).hidden).toBe(true); + expect((win.document.getElementById("interview-view") as any).hidden).toBe(true); + }); + + it("shows the action chip only when board.action is set", () => { + (win as any).render(boardVm()); + expect((win.document.getElementById("board-action") as any).hidden).toBe(true); + (win as any).render(boardVm("triaging")); + const chip = win.document.getElementById("board-action") as any; + expect(chip.hidden).toBe(false); + expect(chip.textContent).toBe("triaging"); + }); + + it("hides backlog-view on a non-backlog (rpi) loop", () => { + const s = applyBeat(initialState(), { type: "session.begin", task: "t", host: "h" }, 1); + (win as any).render(toViewModel(s)); + expect((win.document.getElementById("backlog-view") as any).hidden).toBe(true); + expect((win.document.getElementById("rpi-view") as any).hidden).toBe(false); + }); + + it("indents a same-column child and shows a reference for a cross-column child", () => { + (win as any).render(hierarchyVm()); + const planCards = win.document.querySelectorAll('#board .board-col[aria-label="Plan"] .bcard'); + const feature = Array.from(planCards).find((c: any) => c.querySelector(".bcard-id")!.textContent === "F") as any; + expect(feature.getAttribute("style")).toContain("margin-left:16px"); + expect(feature.querySelector(".bcard-parent")).toBeNull(); // same-column: nesting, no ref + const doneCard = win.document.querySelector('#board .board-col[aria-label="Done"] .bcard') as any; + expect(doneCard.querySelector(".bcard-parent")!.textContent).toContain("Epic"); + const epic = Array.from(planCards).find((c: any) => c.querySelector(".bcard-id")!.textContent === "E") as any; + expect(epic.getAttribute("style")).toBeNull(); // root: no indent + }); +}); diff --git a/rpi-cockpit/tests/bridge.test.ts b/rpi-cockpit/tests/bridge.test.ts new file mode 100644 index 000000000..2f7d551d8 --- /dev/null +++ b/rpi-cockpit/tests/bridge.test.ts @@ -0,0 +1,209 @@ +// rpi-cockpit/tests/bridge.test.ts +import { describe, it, expect, vi } from "vitest"; +import { Bridge } from "../src/bridge.js"; + +describe("Bridge", () => { + it("emits state on a beat", () => { + const b = new Bridge(); + const seen = vi.fn(); + b.on("state", seen); + b.emitBeat({ type: "phase.enter", phase: "plan" }); + expect(b.state.phase).toBe("plan"); + expect(seen).toHaveBeenCalledOnce(); + }); + it("blocks presentOptions until resolveDecision is called", async () => { + const b = new Bridge(); + const p = b.presentOptions("pick", [{ id: "a", title: "A" }, { id: "b", title: "B" }]); + expect(b.state.decisions[0].options?.length).toBe(2); + b.resolveDecision(b.state.decisions[0].id, "b"); + await expect(p).resolves.toBe("b"); + expect(b.state.decisions[0].status).toBe("answered"); + }); + it("falls back to the recommended option on timeout", async () => { + const b = new Bridge(); + const choice = await b.presentOptions("pick", [{ id: "a", title: "A" }, { id: "b", title: "B", recommended: true }], 5); + expect(choice).toBe("b"); + }); + + it("stamps an id, queues a directive, and emits state", () => { + const b = new Bridge(); + const seen = vi.fn(); + b.on("state", seen); + b.enqueueDirective({ kind: "note", text: "focus on errors" }); + expect(b.state.directives).toHaveLength(1); + expect(b.state.directives[0].id).toMatch(/^s\d+$/); + expect(seen).toHaveBeenCalledOnce(); + }); + + it("drains queued directives and clears them", () => { + const b = new Bridge(); + b.enqueueDirective({ kind: "approach", value: "faster", label: "Move faster" }); + const drained = b.drainDirectives(); + expect(drained).toHaveLength(1); + expect(b.state.directives).toHaveLength(0); + expect(b.drainDirectives()).toHaveLength(0); // idempotent when empty + }); + + it("offerApproaches sets the steer menu", () => { + const b = new Bridge(); + b.offerApproaches("Pick", [{ id: "a", title: "A" }]); + expect(b.state.steerMenu).toMatchObject({ label: "Pick" }); + }); + + it("emits a directive event carrying the stamped directive", () => { + const b = new Bridge(); + const seen = vi.fn(); + b.on("directive", seen); + b.enqueueDirective({ kind: "note", text: "focus on errors" }); + expect(seen).toHaveBeenCalledOnce(); + const stamped = seen.mock.calls[0][0]; + expect(stamped).toMatchObject({ kind: "note", text: "focus on errors" }); + expect(stamped.id).toMatch(/^s\d+$/); + }); + + it("emits a decision event with {id, choiceId} when a pending decision resolves", () => { + const b = new Bridge(); + const seen = vi.fn(); + b.on("decision", seen); + const p = b.presentOptions("pick", [{ id: "a", title: "A" }, { id: "b", title: "B" }]); + const id = b.state.decisions[0].id; + b.resolveDecision(id, "b"); + return p.then((choice) => { + expect(choice).toBe("b"); + expect(seen).toHaveBeenCalledOnce(); + expect(seen.mock.calls[0][0]).toMatchObject({ id, choiceId: "b", prompt: "pick" }); + }); + }); + + it("does not emit a decision event when resolving an unknown id", () => { + const b = new Bridge(); + const seen = vi.fn(); + b.on("decision", seen); + b.resolveDecision("does-not-exist", "b"); + expect(seen).not.toHaveBeenCalled(); + }); + + describe("question primitive", () => { + it("askQuestion appends a pending text decision and resolveQuestion answers it", async () => { + const b = new Bridge(); + const p = b.askQuestion("What is the goal?", 0); + expect(b.state.decisions[0].prompt).toBe("What is the goal?"); + const id = b.state.decisions[0].id; + b.resolveQuestion(id, "ship it"); + expect(await p).toBe("ship it"); + expect(b.state.decisions[0].status).toBe("answered"); + }); + it("askQuestion times out to an empty answer", async () => { + const b = new Bridge(); + expect(await b.askQuestion("q", 5)).toBe(""); + }); + }); + + describe("navigator", () => { + it("requestLaunch enqueues an approach directive and shows the loop", () => { + const b = new Bridge(); + b.requestLaunch("build"); + expect(b.state.view).toBe("loop"); + expect(b.state.activeWorkflow).toBe("build"); + expect(b.state.directives).toHaveLength(1); + expect(b.state.directives[0].kind).toBe("approach"); + expect(b.state.directives[0]).toMatchObject({ value: "build" }); + }); + + it("requestLaunch ignores an unknown workflow id", () => { + const b = new Bridge(); + b.requestLaunch("nope"); + expect(b.state.directives).toHaveLength(0); + expect(b.state.view).toBe("home"); + }); + + it("navigate sets the view", () => { + const b = new Bridge(); + b.navigate("loop"); + expect(b.state.view).toBe("loop"); + b.navigate("home"); + expect(b.state.view).toBe("home"); + }); + + it("openNavigator then closeNavigator toggles navigatorOpen and emits", () => { + const b = new Bridge(); + const seen = vi.fn(); + b.on("state", seen); + b.openNavigator(); + expect(b.state.navigatorOpen).toBe(true); + b.closeNavigator(); + expect(b.state.navigatorOpen).toBe(false); + expect(seen).toHaveBeenCalledTimes(2); + }); + }); + + describe("intervene", () => { + it("enqueues a directive naming the action and agent for a pause", () => { + const b = new Bridge(); + b.intervene("pause", "a1"); + expect(b.state.directives).toHaveLength(1); + const d = b.state.directives[0]; + expect(d.kind).toBe("note"); + expect(d).toMatchObject({ kind: "note" }); + if (d.kind === "note") { + expect(d.text).toContain("pause"); + expect(d.text).toContain("a1"); + } + }); + it("enqueues a spawn directive with no agent id", () => { + const b = new Bridge(); + b.intervene("spawn"); + expect(b.state.directives).toHaveLength(1); + const d = b.state.directives[0]; + if (d.kind === "note") expect(d.text).toContain("spawn"); + }); + }); + + it("logs a decision.timeout entry on the auto-resolve fallback", async () => { + const b = new Bridge(); + await b.presentOptions("pick", [{ id: "a", title: "A" }, { id: "b", title: "B", recommended: true }], 5); + const timeout = b.state.log.find((l) => l.kind === "decision.timeout"); + expect(timeout).toBeDefined(); + expect(timeout!.detail).toContain("b"); + }); + + // ── New tests from task-2 brief ────────────────────────────────────────── + + it("presentOptions appends a pending choice decision and resolves to answered", async () => { + const b = new Bridge(); + const p = b.presentOptions("Pick?", [{ id: "a", title: "A" }], 0, "d1"); + expect(b.state.decisions[0]).toMatchObject({ id: "d1", kind: "choice", status: "pending" }); + b.resolveDecision("d1", "a"); + await expect(p).resolves.toBe("a"); + expect(b.state.decisions[0]).toMatchObject({ status: "answered", answer: "a" }); + }); + + it("askQuestion appends a pending text decision and resolves to answered", async () => { + const b = new Bridge(); + const p = b.askQuestion("Name?", 0, "q1"); + b.resolveQuestion("q1", "Ada"); + await expect(p).resolves.toBe("Ada"); + expect(b.state.decisions[0]).toMatchObject({ kind: "text", status: "answered", answer: "Ada" }); + }); + + it("revise re-opens a decision, supersedes downstream, and enqueues a directive", async () => { + const b = new Bridge(); + b.resolveDecision("d1", "a"); // no-op guard + const p1 = b.presentOptions("One?", [{ id: "a", title: "A" }], 0, "d1"); b.resolveDecision("d1", "a"); await p1; + const p2 = b.askQuestion("Two?", 0, "q2"); b.resolveQuestion("q2", "x"); await p2; + b.revise("d1"); + expect(b.state.decisions.map((d) => d.status)).toEqual(["pending", "superseded"]); + expect(b.state.directives.at(-1)?.kind).toBe("note"); + expect((b.state.directives.at(-1) as { text: string }).text).toContain("revise"); + }); + + it("presentOptions auto-resolves to the recommended option on timeout", async () => { + const b = new Bridge(); + const p = b.presentOptions("Pick?", [{ id: "a", title: "A" }, { id: "b", title: "B", recommended: true }], 20, "d1"); + await expect(p).resolves.toBe("b"); + }); + + it("setHostElicits sets the flag", () => { + const b = new Bridge(); b.setHostElicits(true); expect(b.state.hostElicits).toBe(true); + }); +}); diff --git a/rpi-cockpit/tests/catalog.test.ts b/rpi-cockpit/tests/catalog.test.ts new file mode 100644 index 000000000..9c331df73 --- /dev/null +++ b/rpi-cockpit/tests/catalog.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { WORKFLOWS } from "../src/catalog.js"; + +describe("WORKFLOWS catalog", () => { + it("has the six front-door workflows", () => { + expect(WORKFLOWS.map((w) => w.id)).toEqual(["build", "review", "plan", "docs", "data", "coach"]); + }); + + it("gives every workflow a name, hint, description, and intent", () => { + for (const w of WORKFLOWS) { + expect(w.name.length).toBeGreaterThan(0); + expect(w.hint.length).toBeGreaterThan(0); + expect(w.description.length).toBeGreaterThan(0); + expect(w.intent.length).toBeGreaterThan(0); + } + }); + + it("has unique ids", () => { + expect(new Set(WORKFLOWS.map((w) => w.id)).size).toBe(WORKFLOWS.length); + }); +}); diff --git a/rpi-cockpit/tests/codemap-client.test.ts b/rpi-cockpit/tests/codemap-client.test.ts new file mode 100644 index 000000000..952ea2b43 --- /dev/null +++ b/rpi-cockpit/tests/codemap-client.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Window } from "happy-dom"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { initialState, applyBeat } from "../src/state.js"; +import { toViewModel } from "../src/render.js"; + +const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "public"); + +function boot() { + const html = readFileSync(path.join(PUBLIC, "index.html"), "utf8"); + const js = readFileSync(path.join(PUBLIC, "client.js"), "utf8"); + const win = new Window({ url: "http://127.0.0.1:4399/" }); + win.document.write(html); + (win as any).WebSocket = class { readyState = 1; send() {} close() {} }; + win.eval(js.replace(/^import .*$/gm, "")); + return win; +} + +function codemapVm() { + const s = applyBeat(initialState(), { type: "codemap.set", nodes: [ + { id: "n1", path: "src/a.ts", kind: "file" }, + { id: "n2", path: "src/b.ts", kind: "file" }, + { id: "n3", path: "lib/c.ts", kind: "file" }, + ] }, 1); + return toViewModel(s); +} + +describe("codemap client", () => { + let win: ReturnType; + beforeEach(() => { win = boot(); }); + + it("builds one .cn per node in #cm-world", () => { + (win as any).render(codemapVm()); + const nodes = win.document.querySelectorAll("#cm-world .cn"); + expect(nodes.length).toBe(3); + }); + + it("shows codemap-view and hides the other loop views on the codemap domain", () => { + (win as any).render(codemapVm()); + expect((win.document.getElementById("codemap-view") as any).hidden).toBe(false); + expect((win.document.getElementById("rpi-view") as any).hidden).toBe(true); + expect((win.document.getElementById("findings-view") as any).hidden).toBe(true); + expect((win.document.getElementById("interview-view") as any).hidden).toBe(true); + expect((win.document.getElementById("backlog-view") as any).hidden).toBe(true); + expect((win.document.getElementById("team-view") as any).hidden).toBe(true); + }); + + it("hides codemap-view on a non-codemap (rpi) loop", () => { + const s = applyBeat(initialState(), { type: "session.begin", task: "t", host: "h" }, 1); + (win as any).render(toViewModel(s)); + expect((win.document.getElementById("codemap-view") as any).hidden).toBe(true); + expect((win.document.getElementById("rpi-view") as any).hidden).toBe(false); + }); + + it("adds .focused to the matching .cn on a focus view-model", () => { + let s = applyBeat(initialState(), { type: "codemap.set", nodes: [ + { id: "n1", path: "src/a.ts", kind: "file" }, + { id: "n2", path: "src/b.ts", kind: "file" }, + ] }, 1); + s = applyBeat(s, { type: "codemap.focus", id: "n2" }, 2); + (win as any).render(toViewModel(s)); + const focused = win.document.querySelectorAll("#cm-world .cn.focused"); + expect(focused.length).toBe(1); + expect((focused[0] as any).dataset.node).toBe("n2"); + }); + + it("adds .read and .edited to the matching .cn on a touch view-model", () => { + let s = applyBeat(initialState(), { type: "codemap.set", nodes: [ + { id: "n1", path: "src/a.ts", kind: "file" }, + { id: "n2", path: "src/b.ts", kind: "file" }, + ] }, 1); + s = applyBeat(s, { type: "codemap.touch", id: "n1", kind: "read" }, 2); + s = applyBeat(s, { type: "codemap.touch", id: "n2", kind: "edit" }, 3); + (win as any).render(toViewModel(s)); + const read = win.document.querySelector('#cm-world .cn[data-node="n1"]') as any; + const edited = win.document.querySelector('#cm-world .cn[data-node="n2"]') as any; + expect(read.classList.contains("read")).toBe(true); + expect(edited.classList.contains("edited")).toBe(true); + }); + + it("build-once: a focus-only re-render keeps the same .cn elements (no rebuild)", () => { + let s = applyBeat(initialState(), { type: "codemap.set", nodes: [ + { id: "n1", path: "src/a.ts", kind: "file" }, + { id: "n2", path: "src/b.ts", kind: "file" }, + ] }, 1); + (win as any).render(toViewModel(s)); + const before = win.document.querySelectorAll("#cm-world .cn"); + const firstEl = before[0]; + // re-render with the SAME node set but a new focus + s = applyBeat(s, { type: "codemap.focus", id: "n1" }, 2); + (win as any).render(toViewModel(s)); + const after = win.document.querySelectorAll("#cm-world .cn"); + expect(after.length).toBe(before.length); + // the same DOM element object must survive the focus-only re-render + expect(after[0]).toBe(firstEl); + expect((after[0] as any).classList.contains("focused")).toBe(true); + }); +}); diff --git a/rpi-cockpit/tests/context-client.test.ts b/rpi-cockpit/tests/context-client.test.ts new file mode 100644 index 000000000..975daaa49 --- /dev/null +++ b/rpi-cockpit/tests/context-client.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Window } from "happy-dom"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { initialState, applyBeat } from "../src/state.js"; +import { toViewModel } from "../src/render.js"; + +const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "public"); + +function boot() { + const html = readFileSync(path.join(PUBLIC, "index.html"), "utf8"); + const js = readFileSync(path.join(PUBLIC, "client.js"), "utf8"); + const win = new Window({ url: "http://127.0.0.1:4399/" }); + win.document.write(html); + (win as any).WebSocket = class { readyState = 1; send() {} close() {} }; + win.eval(js.replace(/^import .*$/gm, "")); + return win; +} + +function contextVm(instructions: string[], skills: string[], collection: string | null) { + const s = applyBeat(initialState(), { type: "context.set", instructions, skills, collection }, 1); + return toViewModel(s); +} + +describe("context client", () => { + let win: ReturnType; + beforeEach(() => { win = boot(); }); + + it("shows the strip and each group with the right chip count when context is full", () => { + (win as any).render(contextVm(["no em-dashes", "lint to zero"], ["tdd"], "hve-core")); + expect((win.document.getElementById("context-strip") as any).hidden).toBe(false); + + const instr = win.document.getElementById("ctx-instructions") as any; + expect(instr.hidden).toBe(false); + expect(instr.querySelectorAll(".ctx-chip").length).toBe(2); + + const skills = win.document.getElementById("ctx-skills") as any; + expect(skills.hidden).toBe(false); + expect(skills.querySelectorAll(".ctx-chip").length).toBe(1); + + const collection = win.document.getElementById("ctx-collection") as any; + expect(collection.hidden).toBe(false); + const collChips = collection.querySelectorAll(".ctx-chip"); + expect(collChips.length).toBe(1); + expect(collChips[0].classList.contains("collection")).toBe(true); + expect(collChips[0].textContent).toBe("hve-core"); + }); + + it("hides the strip and every group when context is all empty", () => { + (win as any).render(contextVm([], [], null)); + expect((win.document.getElementById("context-strip") as any).hidden).toBe(true); + expect((win.document.getElementById("ctx-instructions") as any).hidden).toBe(true); + expect((win.document.getElementById("ctx-skills") as any).hidden).toBe(true); + expect((win.document.getElementById("ctx-collection") as any).hidden).toBe(true); + }); + + it("hides an empty group while its non-empty siblings stay shown", () => { + (win as any).render(contextVm(["no em-dashes"], [], "hve-core")); + expect((win.document.getElementById("context-strip") as any).hidden).toBe(false); + expect((win.document.getElementById("ctx-instructions") as any).hidden).toBe(false); + expect((win.document.getElementById("ctx-skills") as any).hidden).toBe(true); + expect((win.document.getElementById("ctx-collection") as any).hidden).toBe(false); + }); +}); diff --git a/rpi-cockpit/tests/dataprofile-client.test.ts b/rpi-cockpit/tests/dataprofile-client.test.ts new file mode 100644 index 000000000..36647a2b1 --- /dev/null +++ b/rpi-cockpit/tests/dataprofile-client.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Window } from "happy-dom"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { initialState, applyBeat } from "../src/state.js"; +import { toViewModel } from "../src/render.js"; + +const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "public"); +function boot() { + const html = readFileSync(path.join(PUBLIC, "index.html"), "utf8"); + const js = readFileSync(path.join(PUBLIC, "client.js"), "utf8"); + const win = new Window({ url: "http://127.0.0.1:4399/" }); + win.document.write(html); + (win as any).WebSocket = class { readyState = 1; send() {} close() {} }; + win.eval(js.replace(/^import .*$/gm, "")); + return win; +} +function profileVm() { + let s = applyBeat(initialState(), { type: "profile.start", name: "sales.csv", rows: 100, columns: 2, source: "dw" }, 1); + s = applyBeat(s, { type: "column.add", name: "id", dtype: "int", nullPct: 0, distinct: 100, quality: "ok" }, 2); + s = applyBeat(s, { type: "column.add", name: "region", dtype: "category", distinct: 5, stat: "top: US 42%", quality: "warn" }, 3); + return toViewModel(s); +} + +describe("dataprofile client", () => { + let win: ReturnType; + beforeEach(() => { win = boot(); }); + + it("shows the dataprofile view and hides the others on the dataprofile domain", () => { + (win as any).render(profileVm()); + expect((win.document.getElementById("dataprofile-view") as any).hidden).toBe(false); + expect((win.document.getElementById("rpi-view") as any).hidden).toBe(true); + expect((win.document.getElementById("backlog-view") as any).hidden).toBe(true); + }); + + it("renders one row per column with a quality dot of the right class", () => { + (win as any).render(profileVm()); + const rows = win.document.querySelectorAll("#dp-table tbody tr"); + expect(rows.length).toBe(2); + expect(win.document.querySelector("#dp-table .dp-q-ok")).not.toBeNull(); + expect(win.document.querySelector("#dp-table .dp-q-warn")).not.toBeNull(); + expect((win.document.getElementById("dp-name") as any).textContent).toBe("sales.csv"); + }); +}); diff --git a/rpi-cockpit/tests/decision-client.test.ts b/rpi-cockpit/tests/decision-client.test.ts new file mode 100644 index 000000000..2eaeb4f13 --- /dev/null +++ b/rpi-cockpit/tests/decision-client.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Window } from "happy-dom"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { initialState, applyBeat } from "../src/state.js"; +import { toViewModel } from "../src/render.js"; + +const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "public"); + +function boot() { + const html = readFileSync(path.join(PUBLIC, "index.html"), "utf8"); + const js = readFileSync(path.join(PUBLIC, "client.js"), "utf8"); + const win = new Window({ url: "http://127.0.0.1:4399/" }); + win.document.write(html); + (win as any).WebSocket = class { readyState = 1; send() {} close() {} }; + win.eval(js.replace(/^import .*$/gm, "")); + return win; +} + +describe("decision flow", () => { + let win: ReturnType; + beforeEach(() => { win = boot(); }); + + it("renders the decision flow with answered, pending, and revisit affordances", () => { + (win as any).render({ + view: "loop", domain: "rpi", navigatorOpen: false, workflows: [], + context: { instructions: [], skills: [], collection: null }, appFrame: { url: null }, + hostElicits: true, + decisions: [ + { id: "d1", prompt: "Strategy?", kind: "choice", options: [{ id: "a", title: "Blue-green" }], answer: "a", status: "answered" }, + { id: "q2", prompt: "Window?", kind: "text", status: "pending" }, + ], + // minimal RPI fields: + task: "t", host: "h", phase: "implement", phaseLabel: "Implement", phaseNumber: 3, lead: "x", + steps: [{ phase: "implement", status: "active" }], subagents: [], validations: [], + steerMenu: { label: "x", source: "preset", options: [] }, directives: [], screen: null, log: [], + findingGroups: [], board: { target: null, action: null, count: 0, columns: [] }, + team: { orchestrator: null, count: 0, columns: [] }, codemap: { nodes: [], focus: null, touches: {} }, + reviewTarget: null, docType: null, + }); + const rows = (win as any).document.querySelectorAll("#decision-flow .flow-row"); + expect(rows.length).toBe(2); + expect((win as any).document.querySelector('#decision-flow .flow-row[data-decision-id="d1"] [data-revise="d1"]')).toBeTruthy(); + const pending = (win as any).document.querySelector('#decision-flow .flow-row[data-decision-id="q2"]'); + expect(pending?.className).toContain("pending"); + // hostElicits true => choice chips are NOT clickable inputs + expect((win as any).document.querySelector('#decision-flow [data-choice]')).toBeNull(); + }); + + it("renders interactive choice chips when hostElicits is false", () => { + (win as any).render({ + view: "loop", domain: "rpi", navigatorOpen: false, workflows: [], + context: { instructions: [], skills: [], collection: null }, appFrame: { url: null }, + hostElicits: false, + decisions: [ + { id: "d1", prompt: "Pick one?", kind: "choice", options: [{ id: "x", title: "Option X" }], status: "pending" }, + ], + task: "t", host: "h", phase: "implement", phaseLabel: "Implement", phaseNumber: 3, lead: "x", + steps: [{ phase: "implement", status: "active" }], subagents: [], validations: [], + steerMenu: { label: "x", source: "preset", options: [] }, directives: [], screen: null, log: [], + findingGroups: [], board: { target: null, action: null, count: 0, columns: [] }, + team: { orchestrator: null, count: 0, columns: [] }, codemap: { nodes: [], focus: null, touches: {} }, + reviewTarget: null, docType: null, + }); + expect((win as any).document.querySelector('#decision-flow [data-choice]')).not.toBeNull(); + }); +}); diff --git a/rpi-cockpit/tests/e2e.test.ts b/rpi-cockpit/tests/e2e.test.ts new file mode 100644 index 000000000..1153049ac --- /dev/null +++ b/rpi-cockpit/tests/e2e.test.ts @@ -0,0 +1,48 @@ +// rpi-cockpit/tests/e2e.test.ts +import { describe, it, expect, afterEach } from "vitest"; +import WebSocket from "ws"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { Bridge } from "../src/bridge.js"; +import { startServer } from "../src/server.js"; +import { buildMcpServer } from "../src/mcp.js"; + +async function waitFor(pred: () => boolean, timeout = 2000): Promise { + const start = Date.now(); + while (!pred()) { + if (Date.now() - start > timeout) throw new Error("waitFor: condition not met in time"); + await new Promise((r) => setTimeout(r, 5)); + } +} + +let stop: (() => Promise) | null = null; +afterEach(async () => { if (stop) await stop(); stop = null; }); + +describe("end to end", () => { + it("an MCP beat reaches the browser and a decision round-trips", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const server = buildMcpServer(bridge); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await server.connect(st); + const client = new Client({ name: "t", version: "0" }); + await client.connect(ct); + + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`); + const states: any[] = []; + ws.on("message", (d) => states.push(JSON.parse(String(d)))); + await new Promise((r) => ws.on("open", r)); + + await client.callTool({ name: "phase_enter", arguments: { phase: "implement" } }); + await waitFor(() => states.at(-1)?.state?.phase === "implement"); + expect(states.at(-1).state.phase).toBe("implement"); + + const call = client.callTool({ name: "present_options", arguments: { prompt: "pick", options: [{ id: "a", title: "A" }] } }); + await waitFor(() => bridge.state.decisions.some((d) => d.status === "pending")); + ws.send(JSON.stringify({ type: "decide", id: bridge.state.decisions.at(-1)!.id, choiceId: "a" })); + const res: any = await call; + expect(res.content[0].text).toBe("a"); + ws.close(); + }); +}); diff --git a/rpi-cockpit/tests/elicit.test.ts b/rpi-cockpit/tests/elicit.test.ts new file mode 100644 index 000000000..a9576b0f1 --- /dev/null +++ b/rpi-cockpit/tests/elicit.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect } from "vitest"; +import { optionsToElicitSchema, elicitResultToChoice, presentOptionsWithElicitation, questionToElicitSchema, elicitResultToAnswer, askQuestionWithElicitation, presentWorkflows, questionTimeoutMs } from "../src/elicit.js"; +import { afterEach } from "vitest"; +import { Bridge } from "../src/bridge.js"; +import { WORKFLOWS } from "../src/catalog.js"; +import type { OptionItem } from "../src/events.js"; +import type { ElicitResult } from "@modelcontextprotocol/sdk/types.js"; + +const OPTS: OptionItem[] = [ + { id: "a", title: "Minimal patch" }, + { id: "b", title: "Token middleware", recommended: true }, + { id: "c", title: "Full rewrite" }, +]; + +describe("optionsToElicitSchema", () => { + it("builds a form with the prompt as the message and a single required choice", () => { + const f = optionsToElicitSchema("Which approach?", OPTS); + expect(f.message).toBe("Which approach?"); + expect(f.requestedSchema.type).toBe("object"); + expect(f.requestedSchema.required).toEqual(["choice"]); + }); + + it("maps options to oneOf const/title pairs and defaults to the recommended option", () => { + const choice = optionsToElicitSchema("p", OPTS).requestedSchema.properties.choice as { + oneOf: { const: string; title: string }[]; + default: string; + }; + expect(choice.oneOf).toEqual([ + { const: "a", title: "Minimal patch" }, + { const: "b", title: "Token middleware" }, + { const: "c", title: "Full rewrite" }, + ]); + expect(choice.default).toBe("b"); + }); + + it("defaults to the first option when none is recommended", () => { + const choice = optionsToElicitSchema("p", [{ id: "x", title: "X" }, { id: "y", title: "Y" }]) + .requestedSchema.properties.choice as { default: string }; + expect(choice.default).toBe("x"); + }); +}); + +describe("elicitResultToChoice", () => { + it("returns the chosen id on accept with a valid choice", () => { + expect(elicitResultToChoice({ action: "accept", content: { choice: "b" } }, OPTS)).toBe("b"); + }); + it("returns null when the choice is not a string", () => { + expect(elicitResultToChoice({ action: "accept", content: { choice: 42 } }, OPTS)).toBeNull(); + }); + it("returns null on decline", () => { + expect(elicitResultToChoice({ action: "decline" }, OPTS)).toBeNull(); + }); + it("returns null on cancel", () => { + expect(elicitResultToChoice({ action: "cancel" }, OPTS)).toBeNull(); + }); + it("returns null when the choice is not a known option id", () => { + expect(elicitResultToChoice({ action: "accept", content: { choice: "zzz" } }, OPTS)).toBeNull(); + }); + it("returns null when content is missing", () => { + expect(elicitResultToChoice({ action: "accept" }, OPTS)).toBeNull(); + }); +}); + +function fakeServer(opts: { + elicitation: boolean; + respond?: (params: any, signal?: AbortSignal) => Promise; +}) { + let elicitCalls = 0; + return { + elicitCalls: () => elicitCalls, + getClientCapabilities: () => (opts.elicitation ? { elicitation: {} } : {}), + elicitInput: (params: any, o?: { signal?: AbortSignal }) => { + elicitCalls += 1; + return (opts.respond ?? (() => new Promise(() => {})))(params, o?.signal); + }, + }; +} + +describe("presentOptionsWithElicitation", () => { + const OPTS = [ + { id: "a", title: "Minimal" }, + { id: "b", title: "Middleware", recommended: true }, + { id: "c", title: "Rewrite" }, + ]; + + it("with no elicitation capability, resolves only via the pane card", async () => { + const bridge = new Bridge(); + const srv = fakeServer({ elicitation: false }); + const p = presentOptionsWithElicitation(srv, bridge, "Which?", OPTS, 0); + // The pane card is shown; resolve it like the web decide frame would. + const id = bridge.state.decisions.at(-1)!.id; + bridge.resolveDecision(id, "a"); + expect(await p).toBe("a"); + expect(srv.elicitCalls()).toBe(0); + }); + + it("when the elicitation accepts first, resolves with the elicited choice and clears the pane card", async () => { + const bridge = new Bridge(); + const srv = fakeServer({ elicitation: true, respond: async () => ({ action: "accept", content: { choice: "c" } }) }); + const choice = await presentOptionsWithElicitation(srv, bridge, "Which?", OPTS, 0); + expect(choice).toBe("c"); + expect(bridge.state.decisions.at(-1)?.status).toBe("answered"); + }); + + it("when the pane card answers first, resolves with the web choice and aborts the elicitation", async () => { + const bridge = new Bridge(); + let aborted = false; + const srv = fakeServer({ + elicitation: true, + respond: (_p, signal) => + new Promise((_res, rej) => { + signal?.addEventListener("abort", () => { aborted = true; rej(new Error("aborted")); }); + }), + }); + const p = presentOptionsWithElicitation(srv, bridge, "Which?", OPTS, 0); + const id = bridge.state.decisions.at(-1)!.id; + bridge.resolveDecision(id, "b"); + expect(await p).toBe("b"); + expect(aborted).toBe(true); + }); + + it("a declined elicitation does not resolve the decision; the pane card still can", async () => { + const bridge = new Bridge(); + const srv = fakeServer({ elicitation: true, respond: async () => ({ action: "decline" }) }); + const p = presentOptionsWithElicitation(srv, bridge, "Which?", OPTS, 0); + await new Promise((r) => setTimeout(r, 10)); // let the declined elicitation settle + expect(bridge.state.decisions.at(-1)?.status).toBe("pending"); + const id = bridge.state.decisions.at(-1)!.id; + bridge.resolveDecision(id, "a"); + expect(await p).toBe("a"); + }); + + it("presentOptionsWithElicitation forwards the id to the bridge", () => { + const bridge = new Bridge(); + void presentOptionsWithElicitation({ getClientCapabilities: () => ({}), elicitInput: async () => ({ action: "decline" }) }, bridge, "Pick?", [{ id: "a", title: "A" }], 0, "d9"); + expect(bridge.state.decisions[0].id).toBe("d9"); + }); +}); + +describe("question elicitation", () => { + it("questionToElicitSchema builds a free-text answer field", () => { + const f = questionToElicitSchema("Why?"); + expect(f.message).toBe("Why?"); + const ans = f.requestedSchema.properties.answer as { type: string }; + expect(ans.type).toBe("string"); + expect(f.requestedSchema.required).toEqual(["answer"]); + }); + it("elicitResultToAnswer returns the string on accept, null otherwise", () => { + expect(elicitResultToAnswer({ action: "accept", content: { answer: "yes" } })).toBe("yes"); + expect(elicitResultToAnswer({ action: "decline" })).toBeNull(); + expect(elicitResultToAnswer({ action: "accept", content: { answer: 4 } })).toBeNull(); + }); + it("askQuestionWithElicitation: pane answer wins and aborts the elicitation", async () => { + const bridge = new Bridge(); + let aborted = false; + const srv = fakeServer({ elicitation: true, respond: (_p, signal) => new Promise((_r, rej) => { signal?.addEventListener("abort", () => { aborted = true; rej(new Error("a")); }); }) }); + const p = askQuestionWithElicitation(srv, bridge, "Q?", 0); + const id = bridge.state.decisions.at(-1)!.id; + bridge.resolveQuestion(id, "typed"); + expect(await p).toBe("typed"); + expect(aborted).toBe(true); + }); + it("askQuestionWithElicitation: elicitation answer wins and clears the pane", async () => { + const bridge = new Bridge(); + const srv = fakeServer({ elicitation: true, respond: async () => ({ action: "accept", content: { answer: "native" } }) }); + expect(await askQuestionWithElicitation(srv, bridge, "Q?", 0)).toBe("native"); + expect(bridge.state.decisions.at(-1)?.status).toBe("answered"); + }); +}); + +describe("questionTimeoutMs", () => { + const saved = process.env.RPI_COCKPIT_QUESTION_TIMEOUT_MS; + afterEach(() => { + if (saved === undefined) delete process.env.RPI_COCKPIT_QUESTION_TIMEOUT_MS; + else process.env.RPI_COCKPIT_QUESTION_TIMEOUT_MS = saved; + }); + it("defaults to 0 (no auto-resolve) so the interview blocks until answered", () => { + delete process.env.RPI_COCKPIT_QUESTION_TIMEOUT_MS; + expect(questionTimeoutMs()).toBe(0); + }); + it("reads a positive override from the env", () => { + process.env.RPI_COCKPIT_QUESTION_TIMEOUT_MS = "1234"; + expect(questionTimeoutMs()).toBe(1234); + }); + it("ignores a non-positive or invalid override, falling back to 0", () => { + process.env.RPI_COCKPIT_QUESTION_TIMEOUT_MS = "-5"; + expect(questionTimeoutMs()).toBe(0); + process.env.RPI_COCKPIT_QUESTION_TIMEOUT_MS = "nope"; + expect(questionTimeoutMs()).toBe(0); + }); +}); + +describe("presentWorkflows", () => { + it("returns the chosen workflow's intent when the host accepts a native choice", async () => { + const srv = fakeServer({ elicitation: true, respond: async () => ({ action: "accept", content: { choice: "build" } }) }); + const build = WORKFLOWS.find((w) => w.id === "build")!; + expect(await presentWorkflows(srv)).toBe(build.intent); + expect(srv.elicitCalls()).toBe(1); + }); + + it("returns a chat instruction listing the workflow names when the host lacks elicitation", async () => { + const srv = fakeServer({ elicitation: false }); + const out = await presentWorkflows(srv); + expect(out).toContain("does not support inline choices"); + for (const w of WORKFLOWS) expect(out).toContain(w.name); + expect(srv.elicitCalls()).toBe(0); + }); +}); diff --git a/rpi-cockpit/tests/events.test.ts b/rpi-cockpit/tests/events.test.ts new file mode 100644 index 000000000..62b649549 --- /dev/null +++ b/rpi-cockpit/tests/events.test.ts @@ -0,0 +1,63 @@ +// rpi-cockpit/tests/events.test.ts +import { describe, it, expect } from "vitest"; +import { Beat, OptionItem, InboundDirective, SteerMsg } from "../src/events.js"; + +describe("events", () => { + it("parses a valid phase.enter beat", () => { + const b = Beat.parse({ type: "phase.enter", phase: "implement" }); + expect(b).toEqual({ type: "phase.enter", phase: "implement" }); + }); + it("rejects an unknown phase", () => { + expect(() => Beat.parse({ type: "phase.enter", phase: "nope" })).toThrow(); + }); + it("parses an option item", () => { + expect(OptionItem.parse({ id: "b", title: "Token middleware", recommended: true }).id).toBe("b"); + }); + + it("parses interview.start", () => { + expect(Beat.safeParse({ type: "interview.start", docType: "PRD" }).success).toBe(true); + }); + + describe("review beats", () => { + it("parses review.start", () => { + expect(Beat.safeParse({ type: "review.start", target: "branch x" }).success).toBe(true); + }); + it("parses finding.add with optional file and line", () => { + expect(Beat.safeParse({ type: "finding.add", severity: "high", title: "SQL injection", file: "a.ts", line: 12 }).success).toBe(true); + expect(Beat.safeParse({ type: "finding.add", severity: "low", title: "nit" }).success).toBe(true); + }); + it("rejects an unknown severity", () => { + expect(Beat.safeParse({ type: "finding.add", severity: "blocker", title: "x" }).success).toBe(false); + }); + }); +}); + +it("parses an approaches.offer beat", () => { + const b = Beat.parse({ type: "approaches.offer", label: "Pick", options: [{ id: "a", title: "A" }] }); + expect(b).toMatchObject({ type: "approaches.offer", label: "Pick" }); +}); + +it("parses an inbound note directive and rejects an empty one", () => { + expect(InboundDirective.parse({ kind: "note", text: "focus on errors" }).kind).toBe("note"); + expect(() => InboundDirective.parse({ kind: "note", text: "" })).toThrow(); +}); + +it("parses a steer message carrying an approach directive", () => { + const m = SteerMsg.parse({ type: "steer", directive: { kind: "approach", value: "faster", label: "Move faster" } }); + expect(m.directive).toMatchObject({ kind: "approach", value: "faster" }); +}); + +it("parses a screen.show beat with and without a title", () => { + expect(Beat.parse({ type: "screen.show", html: "

hi

", title: "Mockup" })).toMatchObject({ + type: "screen.show", html: "

hi

", title: "Mockup", + }); + expect(Beat.parse({ type: "screen.show", html: "

hi

" })).toMatchObject({ type: "screen.show", html: "

hi

" }); +}); + +it("rejects a screen.show beat with no html", () => { + expect(() => Beat.parse({ type: "screen.show", title: "Mockup" })).toThrow(); +}); + +it("parses a screen.clear beat", () => { + expect(Beat.parse({ type: "screen.clear" })).toEqual({ type: "screen.clear" }); +}); diff --git a/rpi-cockpit/tests/filesink.test.ts b/rpi-cockpit/tests/filesink.test.ts new file mode 100644 index 000000000..2fa010c51 --- /dev/null +++ b/rpi-cockpit/tests/filesink.test.ts @@ -0,0 +1,112 @@ +// rpi-cockpit/tests/filesink.test.ts +import { describe, it, expect, afterEach } from "vitest"; +import { readFile } from "node:fs/promises"; +import { mkdtemp } from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { Bridge } from "../src/bridge.js"; +import { startServer } from "../src/server.js"; + +let stop: (() => Promise) | null = null; +afterEach(async () => { if (stop) await stop(); stop = null; }); + +// Read a JSONL file and parse each non-empty line into an object. +async function readJsonl(file: string): Promise { + const text = await readFile(file, "utf8"); + return text.split("\n").filter((l) => l.trim().length > 0).map((l) => JSON.parse(l)); +} + +// Poll until a predicate holds (the fs append is async + best-effort). +async function until(pred: () => Promise, ms = 500): Promise { + const start = Date.now(); + while (Date.now() - start < ms) { + if (await pred()) return; + await new Promise((r) => setTimeout(r, 10)); + } +} + +describe("file sink", () => { + it("returns the resolved stateDir from startServer", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "rpi-sink-")); + const bridge = new Bridge(); + const srv = await startServer(bridge, 0, { stateDir: dir }); + stop = srv.close; + expect(srv.stateDir).toBe(dir); + }); + + it("defaults the stateDir under os.tmpdir()/rpi-cockpit/ with no opt or env", async () => { + const prev = process.env.RPI_COCKPIT_STATE_DIR; + delete process.env.RPI_COCKPIT_STATE_DIR; + try { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + expect(srv.stateDir).toBe(path.join(os.tmpdir(), "rpi-cockpit", String(srv.port))); + } finally { + if (prev !== undefined) process.env.RPI_COCKPIT_STATE_DIR = prev; + } + }); + + it("appends one JSON line with a numeric ts to directives.jsonl on enqueue", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "rpi-sink-")); + const bridge = new Bridge(); + const srv = await startServer(bridge, 0, { stateDir: dir }); + stop = srv.close; + + bridge.enqueueDirective({ kind: "note", text: "focus on errors" }); + + const file = path.join(dir, "directives.jsonl"); + await until(async () => { + try { return (await readJsonl(file)).length === 1; } catch { return false; } + }); + const rows = await readJsonl(file); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ kind: "note", text: "focus on errors" }); + expect(rows[0].id).toMatch(/^s\d+$/); + expect(typeof rows[0].ts).toBe("number"); + }); + + it("appends the {id, choiceId} line to decisions.jsonl on a resolved decision", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "rpi-sink-")); + const bridge = new Bridge(); + const srv = await startServer(bridge, 0, { stateDir: dir }); + stop = srv.close; + + const choice = bridge.presentOptions("pick", [{ id: "a", title: "A" }, { id: "b", title: "B" }]); + const id = bridge.state.decisions.at(-1)!.id; + bridge.resolveDecision(id, "b"); + expect(await choice).toBe("b"); + + const file = path.join(dir, "decisions.jsonl"); + await until(async () => { + try { return (await readJsonl(file)).length === 1; } catch { return false; } + }); + const rows = await readJsonl(file); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ id, choiceId: "b" }); + expect(typeof rows[0].ts).toBe("number"); + }); + + it("removes the sink listeners on close (a post-close enqueue does not append)", async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "rpi-sink-")); + const bridge = new Bridge(); + const srv = await startServer(bridge, 0, { stateDir: dir }); + + // First enqueue lands while the server is up. + bridge.enqueueDirective({ kind: "note", text: "before close" }); + const file = path.join(dir, "directives.jsonl"); + await until(async () => { + try { return (await readJsonl(file)).length === 1; } catch { return false; } + }); + + await srv.close(); + stop = null; + + // After close, the sink listener must be gone — no second line appears. + bridge.enqueueDirective({ kind: "note", text: "after close" }); + await new Promise((r) => setTimeout(r, 60)); + const rows = await readJsonl(file); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ text: "before close" }); + }); +}); diff --git a/rpi-cockpit/tests/findings-client.test.ts b/rpi-cockpit/tests/findings-client.test.ts new file mode 100644 index 000000000..75170da68 --- /dev/null +++ b/rpi-cockpit/tests/findings-client.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Window } from "happy-dom"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { initialState, applyBeat } from "../src/state.js"; +import { toViewModel } from "../src/render.js"; + +const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "public"); + +function boot() { + const html = readFileSync(path.join(PUBLIC, "index.html"), "utf8"); + const js = readFileSync(path.join(PUBLIC, "client.js"), "utf8"); + const win = new Window({ url: "http://127.0.0.1:4399/" }); + win.document.write(html); + (win as any).WebSocket = class { readyState = 1; send() {} close() {} }; + win.eval(js.replace(/^import .*$/gm, "")); + return win; +} + +function reviewVm() { + let s = applyBeat(initialState(), { type: "review.start", target: "PR 9" }, 1); + s = applyBeat(s, { type: "finding.add", severity: "critical", title: "RCE", file: "a.ts", line: 4, detail: "bad" }, 2); + s = applyBeat(s, { type: "finding.add", severity: "low", title: "nit" }, 3); + return toViewModel(s); +} + +function reviewVmWithPipeline() { + let s = applyBeat(initialState(), { type: "review.start", target: "Security audit" }, 1); + s = applyBeat(s, { type: "subagent.start", name: "Codebase Profiler", role: "profiler" }, 2); + s = applyBeat(s, { type: "subagent.start", name: "Skill Assessor", role: "auth skill" }, 3); + s = applyBeat(s, { type: "finding.add", severity: "high", title: "Missing authz", file: "api.ts", line: 12, detail: "x" }, 4); + return toViewModel(s); +} + +describe("findings client", () => { + let win: ReturnType; + beforeEach(() => { win = boot(); }); + + it("shows the findings view and hides the RPI view on a review domain", () => { + (win as any).render(reviewVm()); + expect((win.document.getElementById("findings-view") as any).hidden).toBe(false); + expect((win.document.getElementById("rpi-view") as any).hidden).toBe(true); + }); + + it("renders one group per non-empty severity with the finding titles", () => { + (win as any).render(reviewVm()); + const groups = win.document.querySelectorAll("#findings .sev-group"); + expect(groups.length).toBe(2); + expect(win.document.querySelector("#findings .finding-title")!.textContent).toBe("RCE"); + }); + + it("shows the RPI view on a non-review loop", () => { + const s = applyBeat(initialState(), { type: "session.begin", task: "t", host: "h" }, 1); + (win as any).render(toViewModel(s)); + expect((win.document.getElementById("rpi-view") as any).hidden).toBe(false); + expect((win.document.getElementById("findings-view") as any).hidden).toBe(true); + }); + + it("renders the live-reviewers pipeline strip from subagents, hidden when none", () => { + (win as any).render(reviewVmWithPipeline()); + const pipe = win.document.getElementById("rev-pipeline") as any; + expect(pipe.hidden).toBe(false); + expect(pipe.querySelectorAll(".sub-card").length).toBe(2); + (win as any).render(reviewVm()); // reviewVm() has no subagents + expect((win.document.getElementById("rev-pipeline") as any).hidden).toBe(true); + }); + + it("emits the finding location as a button carrying path:line, and copying flips its label", () => { + (win as any).render(reviewVmWithPipeline()); + const loc = win.document.querySelector("#findings .finding-loc") as any; + expect(loc.tagName).toBe("BUTTON"); + expect(loc.getAttribute("data-loc")).toBe("api.ts:12"); + loc.click(); + expect(loc.textContent).toBe("copied"); + }); + + it("omits the location button for a finding with no file", () => { + (win as any).render(reviewVm()); // the "nit" low finding has no file + const groups = [...win.document.querySelectorAll("#findings .sev-group")]; + const lowGroup = groups.find((g) => g.textContent!.includes("nit"))!; + expect(lowGroup.querySelector(".finding-loc")).toBeNull(); + }); + + it("renders an open button carrying the file and line", () => { + (win as any).render(reviewVmWithPipeline()); // its high finding is api.ts:12 + const open = win.document.querySelector("#findings .finding-open") as any; + expect(open.tagName).toBe("BUTTON"); + expect(open.getAttribute("data-file")).toBe("api.ts"); + expect(open.getAttribute("data-line")).toBe("12"); + }); +}); diff --git a/rpi-cockpit/tests/flow-client.test.ts b/rpi-cockpit/tests/flow-client.test.ts new file mode 100644 index 000000000..0983a9f28 --- /dev/null +++ b/rpi-cockpit/tests/flow-client.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Window } from "happy-dom"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { initialState, applyBeat } from "../src/state.js"; +import { toViewModel } from "../src/render.js"; + +const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "public"); +function boot() { + const html = readFileSync(path.join(PUBLIC, "index.html"), "utf8"); + const js = readFileSync(path.join(PUBLIC, "client.js"), "utf8"); + const win = new Window({ url: "http://127.0.0.1:4399/" }); + win.document.write(html); + (win as any).WebSocket = class { readyState = 1; send() {} close() {} }; + win.eval(js.replace(/^import .*$/gm, "")); + return win; +} +function flowVm() { + let s = applyBeat(initialState(), { type: "flow.open", title: "hve-core pipeline" }, 1); + s = applyBeat(s, { type: "flownode.add", id: "triage", kind: "workflow", label: "Issue Triage", sub: "copilot", status: "passed" }, 2); + s = applyBeat(s, { type: "flownode.add", id: "impl", kind: "workflow", label: "Implement", sub: "copilot", status: "running" }, 3); + s = applyBeat(s, { type: "flowedge.add", id: "e1", from: "triage", to: "impl", label: "agent-ready", status: "active" }, 4); + // anatomy of triage + s = applyBeat(s, { type: "flownode.add", id: "triage.t", kind: "trigger", label: "issues", scope: "triage" }, 5); + s = applyBeat(s, { type: "flownode.add", id: "triage.a", kind: "agent", label: "triage agent", scope: "triage" }, 6); + s = applyBeat(s, { type: "flowedge.add", id: "triage.e", from: "triage.t", to: "triage.a", scope: "triage", kind: "step" }, 7); + return toViewModel(s); +} + +describe("flow client", () => { + let win: ReturnType; + beforeEach(() => { win = boot(); }); + + it("shows the flow view and hides the others on the flow domain", () => { + (win as any).render(flowVm()); + expect((win.document.getElementById("flow-view") as any).hidden).toBe(false); + expect((win.document.getElementById("rpi-view") as any).hidden).toBe(true); + expect((win.document.getElementById("memory-view") as any).hidden).toBe(true); + }); + + it("renders orchestration workflow nodes with kind + status classes", () => { + (win as any).render(flowVm()); + const nodes = win.document.querySelectorAll("#gw-world .gw-node"); + expect(nodes.length).toBe(2); // only orchestration scope at the top level + expect(win.document.querySelector("#gw-world .gw-k-workflow.gw-s-passed")).not.toBeNull(); + expect(win.document.querySelector("#gw-world .gw-k-workflow.gw-s-running")).not.toBeNull(); + expect((win.document.getElementById("gw-title") as any).textContent).toContain("hve-core pipeline"); + }); + + it("renders an SVG bezier path per edge, with the active class on a firing edge", () => { + (win as any).render(flowVm()); + const paths = win.document.querySelectorAll("#gw-edges path.gw-edge"); + expect(paths.length).toBe(1); // one orchestration edge (triage -> impl) + expect(win.document.querySelector("#gw-edges path.gw-edge.gw-active")).not.toBeNull(); + // edge label rendered + expect((win.document.getElementById("gw-edges") as any).textContent).toContain("agent-ready"); + }); + + it("drills into a workflow's anatomy on click and back returns to orchestration", () => { + (win as any).render(flowVm()); + (win.document.querySelector('#gw-world .gw-node[data-kind="workflow"][data-gw="triage"]') as any) + .dispatchEvent(new win.Event("click", { bubbles: true })); + // now showing triage anatomy: trigger + agent nodes, no workflow nodes + expect(win.document.querySelector('#gw-world .gw-k-workflow')).toBeNull(); + expect(win.document.querySelectorAll('#gw-world .gw-node').length).toBe(2); + expect((win.document.getElementById("gw-back") as any).hidden).toBe(false); + (win.document.getElementById("gw-back") as any).dispatchEvent(new win.Event("click", { bubbles: true })); + expect(win.document.querySelectorAll('#gw-world .gw-k-workflow').length).toBe(2); + }); + + it("selects a node and shows it in the inspector", () => { + (win as any).render(flowVm()); + (win.document.querySelector('#gw-world .gw-node[data-gw="impl"]') as any) + .dispatchEvent(new win.Event("click", { bubbles: true })); + const insp = win.document.getElementById("gw-inspector") as any; + expect(insp.hidden).toBe(false); + expect(insp.textContent).toContain("Implement"); + }); +}); diff --git a/rpi-cockpit/tests/flow-layout.test.ts b/rpi-cockpit/tests/flow-layout.test.ts new file mode 100644 index 000000000..fa6afe89a --- /dev/null +++ b/rpi-cockpit/tests/flow-layout.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Window } from "happy-dom"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "public"); +function boot() { + const html = readFileSync(path.join(PUBLIC, "index.html"), "utf8"); + const js = readFileSync(path.join(PUBLIC, "client.js"), "utf8"); + const win = new Window({ url: "http://127.0.0.1:4399/" }); + win.document.write(html); + (win as any).WebSocket = class { readyState = 1; send() {} close() {} }; + win.eval(js.replace(/^import .*$/gm, "")); + return win; +} +const layout = (win: any, nodes: any[], edges: any[]) => win.computeFlowLayout(nodes, edges); +const N = (id: string) => ({ id, scope: "orchestration", kind: "workflow", label: id, status: "idle" }); +const E = (id: string, from: string, to: string) => ({ id, from, to, scope: "orchestration", kind: "label", status: "idle" }); + +describe("computeFlowLayout", () => { + let win: any; + beforeEach(() => { win = boot(); }); + + it("lays a linear chain into increasing columns", () => { + const pos = layout(win, [N("a"), N("b"), N("c")], [E("e1", "a", "b"), E("e2", "b", "c")]); + expect(pos.a.x).toBeLessThan(pos.b.x); + expect(pos.b.x).toBeLessThan(pos.c.x); + }); + + it("places fan-out targets in the same later column, different rows", () => { + const pos = layout(win, [N("a"), N("b"), N("c")], [E("e1", "a", "b"), E("e2", "a", "c")]); + expect(pos.b.x).toBe(pos.c.x); + expect(pos.a.x).toBeLessThan(pos.b.x); + expect(pos.b.y).not.toBe(pos.c.y); + }); + + it("tolerates a back edge: forward layering is unchanged and it terminates", () => { + const pos = layout(win, [N("a"), N("b"), N("c")], [E("e1", "a", "b"), E("e2", "b", "c"), E("e3", "c", "a")]); + expect(pos.a.x).toBeLessThan(pos.b.x); + expect(pos.b.x).toBeLessThan(pos.c.x); + }); +}); diff --git a/rpi-cockpit/tests/gallery-client.test.ts b/rpi-cockpit/tests/gallery-client.test.ts new file mode 100644 index 000000000..58c3343e2 --- /dev/null +++ b/rpi-cockpit/tests/gallery-client.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Window } from "happy-dom"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { initialState, applyBeat } from "../src/state.js"; +import { toViewModel } from "../src/render.js"; + +const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "public"); +function boot() { + const html = readFileSync(path.join(PUBLIC, "index.html"), "utf8"); + const js = readFileSync(path.join(PUBLIC, "client.js"), "utf8"); + const win = new Window({ url: "http://127.0.0.1:4399/" }); + win.document.write(html); + (win as any).WebSocket = class { readyState = 1; send() {} close() {} }; + win.eval(js.replace(/^import .*$/gm, "")); + return win; +} +function galleryVm() { + const s = applyBeat(initialState(), { type: "gallery.open", title: "My apps", size: "m", items: [ + { id: "u", label: "Local", group: "live", url: "http://localhost:3000/" }, + { id: "h", label: "Snap", group: "live", html: "hi" }, + ] }, 1); + return toViewModel(s); +} + +describe("gallery client", () => { + let win: ReturnType; + beforeEach(() => { win = boot(); }); + + it("shows the gallery view and hides the others on the gallery domain", () => { + (win as any).render(galleryVm()); + expect((win.document.getElementById("gallery-view") as any).hidden).toBe(false); + expect((win.document.getElementById("rpi-view") as any).hidden).toBe(true); + expect((win.document.getElementById("dataprofile-view") as any).hidden).toBe(true); + }); + + it("renders one card per item, a group header, and the right iframe sandbox/src", () => { + (win as any).render(galleryVm()); + const cards = win.document.querySelectorAll("#gl-grid .gl-card"); + expect(cards.length).toBe(2); + expect(win.document.querySelector("#gl-grid .gl-group")?.textContent).toBe("live"); + const urlFrame = win.document.getElementById("gl-thumb-0") as any; + expect(urlFrame.getAttribute("sandbox")).toBe("allow-scripts allow-same-origin allow-forms"); + expect(urlFrame.getAttribute("src")).toBe("http://localhost:3000/"); + const htmlFrame = win.document.getElementById("gl-thumb-1") as any; + expect(htmlFrame.getAttribute("sandbox")).toBe(""); + expect(htmlFrame.srcdoc).toBe("hi"); + }); + + it("every size renders thumbnails (small is no longer a compact box)", () => { + const s = applyBeat(initialState(), { type: "gallery.open", title: "My apps", size: "s", items: [ + { id: "u", label: "Local", group: "live", url: "http://localhost:3000/" }, + { id: "h", label: "Snap", group: "live", html: "hi" }, + ] }, 1); + (win as any).render(toViewModel(s)); + expect(win.document.querySelectorAll("#gl-grid .gl-card.gl-s").length).toBe(0); + expect(win.document.querySelectorAll("#gl-grid iframe").length).toBe(2); + expect((win.document.getElementById("gl-grid") as any).className).toBe("gsize-s"); + }); + + it("opens the lightbox on a card click and closeLightbox() hides it", () => { + (win as any).render(galleryVm()); + (win.document.querySelector("#gl-grid .gl-card") as any).dispatchEvent(new win.Event("click", { bubbles: true })); + expect((win.document.getElementById("gl-lightbox") as any).hidden).toBe(false); + // happy-dom's synthetic Event has no `key`, so the Escape keydown path is not + // exercised here; call the close handler directly to assert it hides the overlay. + win.eval("closeLightbox()"); + expect((win.document.getElementById("gl-lightbox") as any).hidden).toBe(true); + }); +}); diff --git a/rpi-cockpit/tests/handlers.test.ts b/rpi-cockpit/tests/handlers.test.ts new file mode 100644 index 000000000..f5dd91047 --- /dev/null +++ b/rpi-cockpit/tests/handlers.test.ts @@ -0,0 +1,37 @@ +// rpi-cockpit/tests/handlers.test.ts +import { describe, it, expect } from "vitest"; +import { Bridge } from "../src/bridge.js"; +import { handlers } from "../src/handlers.js"; + +describe("handlers", () => { + it("phase_enter advances the bridge", async () => { + const b = new Bridge(); + const out = await handlers.phase_enter(b, { phase: "implement" }); + expect(b.state.phase).toBe("implement"); + expect(out).toContain("implement"); + }); + + it("offer_approaches populates the steer menu", () => { + const b = new Bridge(); + const out = handlers.offer_approaches(b, { label: "Pick", options: [{ id: "a", title: "A" }] }); + expect(b.state.steerMenu).toMatchObject({ label: "Pick" }); + expect(out).toContain("1"); + }); + + it("check_directives returns queued directives then drains", () => { + const b = new Bridge(); + expect(handlers.check_directives(b)).toBe("no pending directives"); + b.enqueueDirective({ kind: "note", text: "focus on errors" }); + expect(handlers.check_directives(b)).toBe("note: focus on errors"); + expect(handlers.check_directives(b)).toBe("no pending directives"); + }); + + it("show_screen sets the screen on the bridge and clear_screen removes it", () => { + const b = new Bridge(); + const out = handlers.show_screen(b, { html: "

hi

", title: "Mockup" }); + expect(b.state.screen).toEqual({ html: "

hi

", title: "Mockup" }); + expect(typeof out).toBe("string"); + handlers.clear_screen(b); + expect(b.state.screen).toBeNull(); + }); +}); diff --git a/rpi-cockpit/tests/inbound.test.ts b/rpi-cockpit/tests/inbound.test.ts new file mode 100644 index 000000000..8b76b4ded --- /dev/null +++ b/rpi-cockpit/tests/inbound.test.ts @@ -0,0 +1,144 @@ +// rpi-cockpit/tests/inbound.test.ts +import { describe, it, expect } from "vitest"; +import { Bridge } from "../src/bridge.js"; +import { parseInbound, applyInbound } from "../src/inbound.js"; + +describe("parseInbound", () => { + it("accepts a decide frame", () => { + expect(parseInbound({ type: "decide", id: "d1", choiceId: "a" })) + .toEqual({ type: "decide", id: "d1", choiceId: "a" }); + }); + + it("accepts a steer frame", () => { + const f = parseInbound({ type: "steer", directive: { kind: "note", text: "hello" } }); + expect(f).toEqual({ type: "steer", directive: { kind: "note", text: "hello" } }); + }); + + it("accepts a launch frame", () => { + expect(parseInbound({ type: "launch", workflowId: "build" })) + .toEqual({ type: "launch", workflowId: "build" }); + }); + + it("accepts a navigate frame", () => { + expect(parseInbound({ type: "navigate", screen: "home" })) + .toEqual({ type: "navigate", screen: "home" }); + expect(parseInbound({ type: "navigate", screen: "loop" })) + .toEqual({ type: "navigate", screen: "loop" }); + }); + + it("accepts an answer frame", () => { + expect(parseInbound({ type: "answer", id: "q1", text: "yes" })) + .toEqual({ type: "answer", id: "q1", text: "yes" }); + }); + + it("accepts a navigator frame", () => { + expect(parseInbound({ type: "navigator", open: true })) + .toEqual({ type: "navigator", open: true }); + expect(parseInbound({ type: "navigator", open: false })) + .toEqual({ type: "navigator", open: false }); + }); + + it("accepts an intervene frame with and without agentId", () => { + expect(parseInbound({ type: "intervene", action: "pause", agentId: "a1" })) + .toEqual({ type: "intervene", action: "pause", agentId: "a1" }); + expect(parseInbound({ type: "intervene", action: "spawn" })) + .toEqual({ type: "intervene", action: "spawn" }); + }); + + it("rejects malformed frames -> null", () => { + // wrong/missing types + expect(parseInbound(null)).toBeNull(); + expect(parseInbound("string")).toBeNull(); + expect(parseInbound(42)).toBeNull(); + expect(parseInbound({})).toBeNull(); + expect(parseInbound({ type: "unknown" })).toBeNull(); + // missing fields + expect(parseInbound({ type: "decide", id: "d1" })).toBeNull(); + expect(parseInbound({ type: "decide", choiceId: "a" })).toBeNull(); + expect(parseInbound({ type: "answer", id: "q1" })).toBeNull(); + expect(parseInbound({ type: "launch" })).toBeNull(); + // wrong types + expect(parseInbound({ type: "decide", id: 1, choiceId: "a" })).toBeNull(); + expect(parseInbound({ type: "navigator", open: "yes" })).toBeNull(); + expect(parseInbound({ type: "navigate", screen: "elsewhere" })).toBeNull(); + expect(parseInbound({ type: "intervene", action: "delete" })).toBeNull(); + expect(parseInbound({ type: "intervene", action: "pause", agentId: 5 })).toBeNull(); + // bad steer directive + expect(parseInbound({ type: "steer", directive: { kind: "note", text: "" } })).toBeNull(); + expect(parseInbound({ type: "steer", directive: { kind: "bogus" } })).toBeNull(); + }); +}); + +describe("applyInbound", () => { + it("steer grows the bridge's directives", () => { + const bridge = new Bridge(); + applyInbound(bridge, parseInbound({ type: "steer", directive: { kind: "note", text: "focus" } })!); + expect(bridge.state.directives).toHaveLength(1); + expect(bridge.state.directives[0]).toMatchObject({ kind: "note", text: "focus" }); + }); + + it("navigator {open:true} sets navigatorOpen", () => { + const bridge = new Bridge(); + applyInbound(bridge, parseInbound({ type: "navigator", open: true })!); + expect(bridge.state.navigatorOpen).toBe(true); + applyInbound(bridge, parseInbound({ type: "navigator", open: false })!); + expect(bridge.state.navigatorOpen).toBe(false); + }); + + it("navigate sets the view", () => { + const bridge = new Bridge(); + applyInbound(bridge, parseInbound({ type: "navigate", screen: "loop" })!); + expect(bridge.state.view).toBe("loop"); + applyInbound(bridge, parseInbound({ type: "navigate", screen: "home" })!); + expect(bridge.state.view).toBe("home"); + }); + + it("launch flips the view to loop and sets the active workflow", () => { + const bridge = new Bridge(); + applyInbound(bridge, parseInbound({ type: "launch", workflowId: "build" })!); + expect(bridge.state.view).toBe("loop"); + expect(bridge.state.activeWorkflow).toBe("build"); + }); + + it("intervene enqueues a note directive mentioning the action", () => { + const bridge = new Bridge(); + applyInbound(bridge, parseInbound({ type: "intervene", action: "pause", agentId: "a1" })!); + expect(bridge.state.directives).toHaveLength(1); + const d = bridge.state.directives[0]; + expect(d.kind).toBe("note"); + if (d.kind === "note") { + expect(d.text).toContain("pause"); + expect(d.text).toContain("a1"); + } + }); + + it("parseInbound accepts a valid revise frame", () => { + expect(parseInbound({ type: "revise", id: "d1" })).toEqual({ type: "revise", id: "d1" }); + }); + it("parseInbound rejects a revise frame without a string id", () => { + expect(parseInbound({ type: "revise" })).toBeNull(); + expect(parseInbound({ type: "revise", id: 3 })).toBeNull(); + }); + it("applyInbound revise calls bridge.revise", () => { + const b = new Bridge(); + const p = b.presentOptions("x", [{ id: "a", title: "A" }], 0, "d1"); b.resolveDecision("d1", "a"); void p; + applyInbound(b, { type: "revise", id: "d1" }); + expect(b.state.decisions[0].status).toBe("pending"); + }); + + it("parseInbound accepts an open frame with and without a line", () => { + expect(parseInbound({ type: "open", file: "a.ts", line: 4 })).toEqual({ type: "open", file: "a.ts", line: 4 }); + expect(parseInbound({ type: "open", file: "a.ts" })).toEqual({ type: "open", file: "a.ts" }); + }); + it("parseInbound rejects an open frame with a bad file or line", () => { + expect(parseInbound({ type: "open" })).toBeNull(); + expect(parseInbound({ type: "open", file: 3 })).toBeNull(); + expect(parseInbound({ type: "open", file: "a.ts", line: "4" })).toBeNull(); + }); + it("applyInbound open enqueues an editor directive", () => { + const b = new Bridge(); + applyInbound(b, { type: "open", file: "src/x.ts", line: 9 }); + expect(b.state.directives.at(-1)).toMatchObject({ kind: "note" }); + expect((b.state.directives.at(-1) as { text: string }).text).toContain("src/x.ts:9"); + }); +}); diff --git a/rpi-cockpit/tests/init.test.ts b/rpi-cockpit/tests/init.test.ts new file mode 100644 index 000000000..6ffdeba63 --- /dev/null +++ b/rpi-cockpit/tests/init.test.ts @@ -0,0 +1,206 @@ +// rpi-cockpit/tests/init.test.ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runInit } from "../src/init.js"; + +const here = dirname(fileURLToPath(import.meta.url)); +// The canonical narration contract lives in the package's agents dir. +const CONTRACT_PATH = resolve(here, "..", "agents", "cockpit-instructions.md"); +const CONTRACT_TEXT = readFileSync(CONTRACT_PATH, "utf8").trim(); +// A representative line from the contract that must end up inlined in every surface. +const CONTRACT_NEEDLE = "phase_enter(phase)"; + +let root: string; +let home: string; +// A fixed absolute entryPath simulating the running dist/index.js. +const ENTRY = "/some/abs/repo/rpi-cockpit/dist/index.js"; + +function readJson(p: string) { + return JSON.parse(readFileSync(p, "utf8")); +} + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "rpi-init-root-")); + home = mkdtempSync(join(tmpdir(), "rpi-init-home-")); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); +}); + +function run(host: "claude" | "codex" | "vscode" | "all", codexGlobal = false) { + return runInit({ + root, + entryPath: ENTRY, + contractPath: CONTRACT_PATH, + host, + codexGlobal, + homeDir: home, + }); +} + +describe("runInit --host all", () => { + it("writes .mcp.json with mcpServers.rpi-cockpit (stdio + CLAUDE_PROJECT_DIR + env port)", () => { + run("all"); + const j = readJson(join(root, ".mcp.json")); + const s = j.mcpServers["rpi-cockpit"]; + expect(s.type).toBe("stdio"); + expect(s.command).toBe("node"); + expect(s.args).toEqual(["${CLAUDE_PROJECT_DIR}/rpi-cockpit/dist/index.js"]); + expect(s.env.RPI_COCKPIT_PORT).toBe("4399"); + }); + + it("writes .vscode/mcp.json with servers.rpi-cockpit (stdio + workspaceFolder + cwd)", () => { + run("all"); + const j = readJson(join(root, ".vscode", "mcp.json")); + const s = j.servers["rpi-cockpit"]; + expect(s.type).toBe("stdio"); + expect(s.command).toBe("node"); + expect(s.args).toEqual(["${workspaceFolder}/rpi-cockpit/dist/index.js"]); + expect(s.cwd).toBe("${workspaceFolder}"); + }); + + it("writes .codex/config.toml with [mcp_servers.rpi-cockpit] (absolute entryPath + cwd + startup_timeout_sec)", () => { + run("all"); + const toml = readFileSync(join(root, ".codex", "config.toml"), "utf8"); + expect(toml).toContain("[mcp_servers.rpi-cockpit]"); + expect(toml).toContain('command = "node"'); + expect(toml).toContain(`args = ["${ENTRY}"]`); + expect(toml).toContain(`cwd = "${root}"`); + expect(toml).toContain("startup_timeout_sec = 20"); + }); + + it("writes the narration marker block (with contract text) into CLAUDE.md, AGENTS.md, copilot-instructions.md", () => { + run("all"); + for (const rel of ["CLAUDE.md", "AGENTS.md", join(".github", "copilot-instructions.md")]) { + const p = join(root, rel); + expect(existsSync(p)).toBe(true); + const txt = readFileSync(p, "utf8"); + expect(txt).toContain("rpi-cockpit:narration"); + expect(txt).toContain(CONTRACT_NEEDLE); + } + }); +}); + +describe("runInit --host filters", () => { + it("claude -> only .mcp.json + CLAUDE.md", () => { + run("claude"); + expect(existsSync(join(root, ".mcp.json"))).toBe(true); + expect(existsSync(join(root, "CLAUDE.md"))).toBe(true); + expect(existsSync(join(root, ".vscode", "mcp.json"))).toBe(false); + expect(existsSync(join(root, ".codex", "config.toml"))).toBe(false); + expect(existsSync(join(root, "AGENTS.md"))).toBe(false); + }); + + it("vscode -> only .vscode/mcp.json + copilot-instructions.md", () => { + run("vscode"); + expect(existsSync(join(root, ".vscode", "mcp.json"))).toBe(true); + expect(existsSync(join(root, ".github", "copilot-instructions.md"))).toBe(true); + expect(existsSync(join(root, ".mcp.json"))).toBe(false); + expect(existsSync(join(root, ".codex", "config.toml"))).toBe(false); + }); + + it("codex -> only .codex/config.toml + AGENTS.md", () => { + run("codex"); + expect(existsSync(join(root, ".codex", "config.toml"))).toBe(true); + expect(existsSync(join(root, "AGENTS.md"))).toBe(true); + expect(existsSync(join(root, ".mcp.json"))).toBe(false); + expect(existsSync(join(root, ".vscode", "mcp.json"))).toBe(false); + }); + + it("codexGlobal -> writes config.toml under homeDir/.codex, not root/.codex", () => { + run("codex", true); + expect(existsSync(join(home, ".codex", "config.toml"))).toBe(true); + expect(existsSync(join(root, ".codex", "config.toml"))).toBe(false); + }); +}); + +describe("runInit preservation", () => { + it("preserves an unrelated pre-existing mcpServers entry in .mcp.json", () => { + writeFileSync( + join(root, ".mcp.json"), + JSON.stringify({ mcpServers: { other: { command: "x" } }, topLevelKey: 1 }, null, 2), + ); + run("claude"); + const j = readJson(join(root, ".mcp.json")); + expect(j.mcpServers.other).toEqual({ command: "x" }); + expect(j.topLevelKey).toBe(1); + expect(j.mcpServers["rpi-cockpit"].type).toBe("stdio"); + }); + + it("preserves pre-existing .vscode/mcp.json servers and inputs", () => { + mkdirSync(join(root, ".vscode"), { recursive: true }); + writeFileSync( + join(root, ".vscode", "mcp.json"), + JSON.stringify({ servers: { keep: { command: "y" } }, inputs: [{ id: "tok" }] }, null, 2), + ); + run("vscode"); + const j = readJson(join(root, ".vscode", "mcp.json")); + expect(j.servers.keep).toEqual({ command: "y" }); + expect(j.inputs).toEqual([{ id: "tok" }]); + expect(j.servers["rpi-cockpit"].type).toBe("stdio"); + }); + + it("preserves pre-existing copilot-instructions.md content (never clobbers)", () => { + mkdirSync(join(root, ".github"), { recursive: true }); + const existing = "# Existing Copilot guidance\n\nKeep me intact.\n"; + writeFileSync(join(root, ".github", "copilot-instructions.md"), existing); + run("vscode"); + const txt = readFileSync(join(root, ".github", "copilot-instructions.md"), "utf8"); + expect(txt).toContain("Keep me intact."); + expect(txt).toContain("rpi-cockpit:narration"); + expect(txt).toContain(CONTRACT_NEEDLE); + // Embedded under the host doc's own H1: the contract's heading is demoted so + // it does not introduce a second top-level heading (MD025). + expect(txt).toContain("## Cockpit instrumentation"); + expect(txt).not.toMatch(/^# Cockpit instrumentation$/m); + }); +}); + +describe("runInit idempotency", () => { + it("running twice yields identical files (no duplicate blocks/tables)", () => { + run("all"); + const snapshot: Record = {}; + const files = [ + join(root, ".mcp.json"), + join(root, ".vscode", "mcp.json"), + join(root, ".codex", "config.toml"), + join(root, "CLAUDE.md"), + join(root, "AGENTS.md"), + join(root, ".github", "copilot-instructions.md"), + ]; + for (const f of files) snapshot[f] = readFileSync(f, "utf8"); + + run("all"); + for (const f of files) { + expect(readFileSync(f, "utf8")).toBe(snapshot[f]); + } + + // Belt-and-suspenders: exactly one narration block and one codex table. + const claude = readFileSync(join(root, "CLAUDE.md"), "utf8"); + const beginCount = (claude.match(/rpi-cockpit:narration:begin/g) || []).length; + expect(beginCount).toBe(1); + const toml = readFileSync(join(root, ".codex", "config.toml"), "utf8"); + const tableCount = (toml.match(/\[mcp_servers\.rpi-cockpit\]/g) || []).length; + expect(tableCount).toBe(1); + }); + + it("does not duplicate the mcpServers.rpi-cockpit entry across runs", () => { + run("claude"); + run("claude"); + const j = readJson(join(root, ".mcp.json")); + expect(Object.keys(j.mcpServers)).toEqual(["rpi-cockpit"]); + }); +}); + +describe("runInit contract", () => { + it("inlines the FULL contract text (no @-includes)", () => { + run("claude"); + const txt = readFileSync(join(root, "CLAUDE.md"), "utf8"); + expect(txt).toContain(CONTRACT_TEXT); + expect(txt).not.toContain("@agents/cockpit-instructions.md"); + }); +}); diff --git a/rpi-cockpit/tests/interview-client.test.ts b/rpi-cockpit/tests/interview-client.test.ts new file mode 100644 index 000000000..c36bfa214 --- /dev/null +++ b/rpi-cockpit/tests/interview-client.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Window } from "happy-dom"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { initialState, applyBeat } from "../src/state.js"; +import { toViewModel } from "../src/render.js"; +import { Bridge } from "../src/bridge.js"; + +const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "public"); + +function boot() { + const html = readFileSync(path.join(PUBLIC, "index.html"), "utf8"); + const js = readFileSync(path.join(PUBLIC, "client.js"), "utf8"); + const win = new Window({ url: "http://127.0.0.1:4399/" }); + win.document.write(html); + (win as any).WebSocket = class { readyState = 1; send() {} close() {} }; + win.eval(js.replace(/^import .*$/gm, "")); + return win; +} + +function interviewVm() { + const b = new Bridge(); + b.emitBeat({ type: "interview.start", docType: "PRD" }); + void b.askQuestion("What problem?", 0); + return toViewModel(b.state); +} + +describe("interview client", () => { + let win: ReturnType; + beforeEach(() => { win = boot(); }); + + it("shows interview-view and hides rpi-view and findings-view on the interview domain", () => { + (win as any).render(interviewVm()); + expect((win.document.getElementById("interview-view") as any).hidden).toBe(false); + expect((win.document.getElementById("rpi-view") as any).hidden).toBe(true); + expect((win.document.getElementById("findings-view") as any).hidden).toBe(true); + }); + + it("renders the doc iframe when a screen is present", () => { + const b = new Bridge(); + b.emitBeat({ type: "interview.start", docType: "PRD" }); + (b.state as any).screen = { html: "

Draft content

" }; + (win as any).render(toViewModel(b.state)); + const doc = win.document.getElementById("iv-doc") as HTMLIFrameElement; + expect(doc).not.toBeNull(); + expect((doc as any).srcdoc).toContain("Draft content"); + }); + + it("shows rpi-view and hides interview-view on a session.begin (rpi) loop", () => { + const s = applyBeat(initialState(), { type: "session.begin", task: "t", host: "h" }, 1); + (win as any).render(toViewModel(s)); + expect((win.document.getElementById("rpi-view") as any).hidden).toBe(false); + expect((win.document.getElementById("interview-view") as any).hidden).toBe(true); + }); +}); + +function steppedVm() { + let s = applyBeat(initialState(), { type: "interview.start", docType: "ADR" }, 1); + s = applyBeat(s, { type: "steps.set", steps: ["Frame", "Decide", "Govern"], current: 1, label: "ADR" }, 2); + return toViewModel(s); +} + +describe("interview layout", () => { + let win: ReturnType; + beforeEach(() => { win = boot(); }); + + it("wraps the conversation in .iv-convo with the draft iframe as a sibling", () => { + (win as any).render(steppedVm()); // existing helper from the stepper tests + const view = win.document.getElementById("interview-view")!; + const convo = view.querySelector(".iv-convo")!; + expect(convo).not.toBeNull(); + expect(convo.querySelector("#iv-steps")).not.toBeNull(); + expect(convo.querySelector(".flow-slot")).not.toBeNull(); + // the draft iframe is a direct child of #interview-view, NOT inside .iv-convo + const ivDoc = win.document.getElementById("iv-doc")!; + expect(ivDoc).not.toBeNull(); + expect(ivDoc.parentElement!.id).toBe("interview-view"); + expect(convo.querySelector("#iv-doc")).toBeNull(); + }); +}); + +describe("interview stepper", () => { + let win: ReturnType; + beforeEach(() => { win = boot(); }); + + it("renders done/total and a mini-bar on the active step with progress", () => { + let s = applyBeat(initialState(), { type: "interview.start", docType: "ADR" }, 1); + s = applyBeat(s, { type: "steps.set", steps: ["Frame", "Decide", "Govern"], current: 1, progress: { done: 2, total: 4 } }, 2); + (win as any).render(toViewModel(s)); + const active = win.document.querySelector("#iv-steps .iv-step-active")!; + expect(active.querySelector(".iv-step-prog")!.textContent).toBe("2/4"); + const bar = active.querySelector(".iv-step-bar > i") as any; + expect(bar.getAttribute("style")).toContain("width:50%"); + }); + + it("renders the interview stepper with done/active/pending pills", () => { + (win as any).render(steppedVm()); + const steps = win.document.getElementById("iv-steps") as any; + expect(steps.hidden).toBe(false); + const pills = win.document.querySelectorAll("#iv-steps .iv-step"); + expect(pills.length).toBe(3); + expect(win.document.querySelector("#iv-steps .iv-step-done")).not.toBeNull(); + expect(win.document.querySelector("#iv-steps .iv-step-active")!.textContent).toContain("Decide"); + expect(win.document.querySelector("#iv-steps .iv-step-pending")!.textContent).toContain("Govern"); + }); + + it("hides the stepper when no program is declared", () => { + let s = applyBeat(initialState(), { type: "interview.start", docType: "PRD" }, 1); + (win as any).render(toViewModel(s)); + expect((win.document.getElementById("iv-steps") as any).hidden).toBe(true); + }); +}); diff --git a/rpi-cockpit/tests/live.test.ts b/rpi-cockpit/tests/live.test.ts new file mode 100644 index 000000000..f9766fbe8 --- /dev/null +++ b/rpi-cockpit/tests/live.test.ts @@ -0,0 +1,105 @@ +// rpi-cockpit/tests/live.test.ts +import { describe, it, expect, afterEach } from "vitest"; +import os from "node:os"; +import path from "node:path"; +import { mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import WebSocket from "ws"; +import { Bridge } from "../src/bridge.js"; +import { initialState } from "../src/state.js"; +import { runLiveConsumer, tailInbox } from "../src/live.js"; + +// Cleanup registry: live servers to close and dirs to remove after each test. +const closers: (() => Promise | void)[] = []; +const dirs: string[] = []; +afterEach(async () => { + for (const c of closers.splice(0)) await c(); + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +function tempDir(tag: string): string { + const dir = path.join(os.tmpdir(), `rpi-cockpit-live-${tag}-${process.pid}-${Date.now()}-${dirs.length}`); + mkdirSync(dir, { recursive: true }); + dirs.push(dir); + return dir; +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe("runLiveConsumer", () => { + it("renders the state.json snapshot to a connecting client and reflects updates", async () => { + const dir = tempDir("render"); + const st = { ...initialState(), task: "from file", host: "host-a", domain: "rpi" as const, view: "loop" as const }; + writeFileSync(path.join(dir, "state.json"), JSON.stringify(st)); + + const srv = await runLiveConsumer({ stateDir: dir, port: 0 }); + closers.push(srv.close); + + const ws = new WebSocket(`${srv.url.replace("http", "ws")}`); + const first = await new Promise((res, rej) => { + ws.on("message", (d) => res(JSON.parse(String(d)))); + ws.on("error", rej); + }); + expect(first.type).toBe("state"); + expect(first.view.task).toBe("from file"); + expect(first.view.view).toBe("loop"); + + // Update the file; the polling watch (150ms) should pick it up and broadcast. + const next = new Promise((res) => ws.on("message", (d) => res(JSON.parse(String(d))))); + writeFileSync(path.join(dir, "state.json"), JSON.stringify({ ...st, task: "updated task" })); + await sleep(350); + const updated = await next; + expect(updated.view.task).toBe("updated task"); + ws.close(); + }); + + it("routes an inbound WS frame to inbox.jsonl instead of a local bridge", async () => { + const dir = tempDir("route"); + const srv = await runLiveConsumer({ stateDir: dir, port: 0 }); + closers.push(srv.close); + + const ws = new WebSocket(`${srv.url.replace("http", "ws")}`); + await new Promise((r) => ws.on("open", r)); + ws.send(JSON.stringify({ type: "steer", directive: { kind: "note", text: "to producer" } })); + await sleep(60); + ws.close(); + + const inbox = readFileSync(path.join(dir, "inbox.jsonl"), "utf8").trim().split("\n"); + expect(inbox).toHaveLength(1); + expect(JSON.parse(inbox[0])).toEqual({ type: "steer", directive: { kind: "note", text: "to producer" } }); + }); +}); + +describe("tailInbox (producer)", () => { + it("does not replay inbox lines that predate the tail (seek-to-end on startup)", async () => { + const dir = tempDir("tail"); + const bridge = new Bridge(); + // A line present before tailing starts is from a prior session: a producer + // (re)start must NOT re-apply it, or every historical intent would replay. + writeFileSync( + path.join(dir, "inbox.jsonl"), + JSON.stringify({ type: "steer", directive: { kind: "note", text: "old" } }) + "\n", + ); + const t = tailInbox(dir, bridge); + closers.push(() => t.stop()); + await sleep(60); + expect(bridge.state.directives).toHaveLength(0); + }); + + it("applies lines appended after tailing begins", async () => { + const dir = tempDir("tail2"); + const bridge = new Bridge(); + writeFileSync(path.join(dir, "inbox.jsonl"), ""); + const t = tailInbox(dir, bridge); + closers.push(() => t.stop()); + await sleep(60); + expect(bridge.state.directives).toHaveLength(0); + // Append a frame; the polling watch (150ms) should apply it. + const { appendFileSync } = await import("node:fs"); + appendFileSync( + path.join(dir, "inbox.jsonl"), + JSON.stringify({ type: "navigator", open: true }) + "\n", + ); + await sleep(350); + expect(bridge.state.navigatorOpen).toBe(true); + }); +}); diff --git a/rpi-cockpit/tests/mcp.test.ts b/rpi-cockpit/tests/mcp.test.ts new file mode 100644 index 000000000..a58ba684f --- /dev/null +++ b/rpi-cockpit/tests/mcp.test.ts @@ -0,0 +1,432 @@ +// rpi-cockpit/tests/mcp.test.ts +import { describe, it, expect } from "vitest"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js"; +import { Bridge } from "../src/bridge.js"; +import { buildMcpServer } from "../src/mcp.js"; +import { WORKFLOWS } from "../src/catalog.js"; + +describe("mcp face", () => { + it("phase_enter tool advances the bridge", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await server.connect(serverT); + const client = new Client({ name: "test", version: "0" }); + await client.connect(clientT); + + await client.callTool({ name: "phase_enter", arguments: { phase: "review" } }); + expect(bridge.state.phase).toBe("review"); + }); + + it("registers the steering and screen tools and lists thirty-three total", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await server.connect(serverT); + const client = new Client({ name: "test", version: "0" }); + await client.connect(clientT); + + const { tools } = await client.listTools(); + const names = tools.map((t) => t.name).sort(); + expect(names).toContain("offer_approaches"); + expect(names).toContain("check_directives"); + expect(names).toContain("show_screen"); + expect(names).toContain("clear_screen"); + expect(names).toContain("present_workflows"); + expect(names).toContain("open_navigator"); + expect(names).toContain("set_context"); + expect(names).toContain("set_app_frame"); + expect(names).toContain("team_start"); + expect(names).toContain("add_agent"); + expect(names).toContain("update_agent"); + expect(names).toContain("remove_agent"); + expect(names).toContain("codemap_set"); + expect(names).toContain("codemap_focus"); + expect(names).toContain("codemap_touch"); + expect(names).toContain("dataset_profile"); + expect(names).toContain("add_column"); + expect(names).toContain("set_steps"); + expect(names).toContain("gallery_open"); + expect(names).toContain("gallery_add"); + expect(names).toContain("gallery_clear"); + expect(tools).toHaveLength(45); + expect(names).toContain("promptlab_start"); + expect(names).toContain("add_case"); + expect(names).toContain("memory_open"); + expect(names).toContain("add_memory"); + expect(names).toContain("add_handoff"); + expect(names).toContain("flow_open"); + expect(names).toContain("add_flow_node"); + expect(names).toContain("add_flow_edge"); + expect(names).toContain("flow_focus"); + + await client.callTool({ name: "offer_approaches", arguments: { label: "Pick", options: [{ id: "a", title: "A" }] } }); + expect(bridge.state.steerMenu).toMatchObject({ label: "Pick" }); + }); + + it("open_navigator opens the navigator pop-up in the bridge state", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await server.connect(serverT); + const client = new Client({ name: "test", version: "0" }); + await client.connect(clientT); + + expect(bridge.state.navigatorOpen).toBe(false); + await client.callTool({ name: "open_navigator", arguments: {} }); + expect(bridge.state.navigatorOpen).toBe(true); + }); + + it("show_screen and clear_screen tools drive the bridge screen state", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await server.connect(serverT); + const client = new Client({ name: "test", version: "0" }); + await client.connect(clientT); + + await client.callTool({ name: "show_screen", arguments: { html: "

hi

", title: "Mockup" } }); + expect(bridge.state.screen).toEqual({ html: "

hi

", title: "Mockup" }); + await client.callTool({ name: "clear_screen", arguments: {} }); + expect(bridge.state.screen).toBeNull(); + }); + + it("present_options resolves from a native elicitation when the host supports it", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const client = new Client( + { name: "test-client", version: "0.0.1" }, + { capabilities: { elicitation: {} } }, + ); + client.setRequestHandler(ElicitRequestSchema, async () => ({ action: "accept", content: { choice: "b" } })); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + const res = await client.callTool({ + name: "present_options", + arguments: { prompt: "Which approach?", options: [{ id: "a", title: "A" }, { id: "b", title: "B", recommended: true }] }, + }); + const out = (res.content as { type: string; text: string }[])[0].text; + expect(out).toContain("b"); + + await client.close(); + await server.close(); + }); + + it("present_workflows returns the chosen workflow's intent via a native choice card", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const client = new Client( + { name: "test-client", version: "0.0.1" }, + { capabilities: { elicitation: {} } }, + ); + client.setRequestHandler(ElicitRequestSchema, async () => ({ action: "accept", content: { choice: "review" } })); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + const res = await client.callTool({ name: "present_workflows", arguments: {} }); + const out = (res.content as { type: string; text: string }[])[0].text; + const review = WORKFLOWS.find((w) => w.id === "review")!; + expect(out).toContain(review.intent); + + await client.close(); + await server.close(); + }); + + it("review_start and add_finding drive the findings state", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const client = new Client({ name: "t", version: "0.0.1" }, { capabilities: {} }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + await client.callTool({ name: "review_start", arguments: { target: "PR 1" } }); + await client.callTool({ name: "add_finding", arguments: { severity: "high", title: "bug", file: "a.ts", line: 2 } }); + expect(bridge.state.domain).toBe("review"); + expect(bridge.state.reviewTarget).toBe("PR 1"); + expect(bridge.state.findings).toHaveLength(1); + expect(bridge.state.findings[0]).toMatchObject({ severity: "high", title: "bug" }); + await client.close(); + await server.close(); + }); + + it("the backlog tools drive the board state", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const client = new Client({ name: "t", version: "0.0.1" }, { capabilities: {} }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + await client.callTool({ name: "backlog_start", arguments: { target: "Sprint 4", columns: ["Todo", "Done"] } }); + expect(bridge.state.domain).toBe("backlog"); + expect(bridge.state.boardColumns).toEqual(["Todo", "Done"]); + await client.callTool({ name: "add_item", arguments: { id: "I1", title: "fix", column: "Todo", kind: "bug", tier: "T2" } }); + expect(bridge.state.boardItems).toHaveLength(1); + expect(bridge.state.boardItems[0]).toMatchObject({ id: "I1", title: "fix", column: "Todo", kind: "bug", tier: "T2" }); + await client.callTool({ name: "move_item", arguments: { id: "I1", column: "Done" } }); + expect(bridge.state.boardItems[0].column).toBe("Done"); + await client.callTool({ name: "set_backlog_action", arguments: { text: "triaging" } }); + expect(bridge.state.boardAction).toBe("triaging"); + await client.callTool({ name: "set_backlog_action", arguments: { text: null } }); + expect(bridge.state.boardAction).toBeNull(); + await client.close(); + await server.close(); + }); + + it("the team tools drive the roster state", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const client = new Client({ name: "t", version: "0.0.1" }, { capabilities: {} }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + await client.callTool({ name: "team_start", arguments: { task: "ship", orchestrator: "Lead" } }); + expect(bridge.state.domain).toBe("team"); + expect(bridge.state.orchestrator).toBe("Lead"); + await client.callTool({ name: "add_agent", arguments: { id: "a1", name: "Worker", role: "impl", status: "running" } }); + expect(bridge.state.teamAgents).toHaveLength(1); + expect(bridge.state.teamAgents[0]).toMatchObject({ id: "a1", name: "Worker", role: "impl", status: "running" }); + await client.callTool({ name: "update_agent", arguments: { id: "a1", status: "done", action: "shipped" } }); + expect(bridge.state.teamAgents[0]).toMatchObject({ status: "done", action: "shipped" }); + await client.callTool({ name: "remove_agent", arguments: { id: "a1" } }); + expect(bridge.state.teamAgents).toHaveLength(0); + await client.close(); + await server.close(); + }); + + it("the codemap tools drive the codemap state", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const client = new Client({ name: "t", version: "0.0.1" }, { capabilities: {} }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + await client.callTool({ name: "codemap_set", arguments: { nodes: [{ id: "n1", path: "src/a.ts", kind: "file" }, { id: "n2", path: "src/b.ts", kind: "file" }] } }); + expect(bridge.state.domain).toBe("codemap"); + expect(bridge.state.codemapNodes).toHaveLength(2); + await client.callTool({ name: "codemap_focus", arguments: { id: "n1" } }); + expect(bridge.state.codemapFocus).toBe("n1"); + await client.callTool({ name: "codemap_touch", arguments: { id: "n2", kind: "edit" } }); + expect(bridge.state.codemapTouches.n2).toBe("edit"); + await client.close(); + await server.close(); + }); + + it("set_context with full args sets all three context fields", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const client = new Client({ name: "t", version: "0.0.1" }, { capabilities: {} }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + await client.callTool({ name: "set_context", arguments: { instructions: ["no em-dashes", "lint to zero"], skills: ["tdd"], collection: "hve-core" } }); + expect(bridge.state.contextInstructions).toEqual(["no em-dashes", "lint to zero"]); + expect(bridge.state.contextSkills).toEqual(["tdd"]); + expect(bridge.state.contextCollection).toBe("hve-core"); + await client.close(); + await server.close(); + }); + + it("set_context with partial args defaults the others to [] and null", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const client = new Client({ name: "t", version: "0.0.1" }, { capabilities: {} }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + await client.callTool({ name: "set_context", arguments: { skills: ["deepsearch"] } }); + expect(bridge.state.contextSkills).toEqual(["deepsearch"]); + expect(bridge.state.contextInstructions).toEqual([]); + expect(bridge.state.contextCollection).toBeNull(); + await client.close(); + await server.close(); + }); + + it("set_app_frame accepts a loopback url and clears on null", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const client = new Client({ name: "t", version: "0.0.1" }, { capabilities: {} }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + await client.callTool({ name: "set_app_frame", arguments: { url: "http://localhost:5173" } }); + expect(bridge.state.appFrameUrl).toBe("http://localhost:5173"); + await client.callTool({ name: "set_app_frame", arguments: { url: null } }); + expect(bridge.state.appFrameUrl).toBeNull(); + await client.close(); + await server.close(); + }); + + it("set_app_frame rejects non-loopback / non-http urls and leaves state unchanged (server guard)", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const client = new Client({ name: "t", version: "0.0.1" }, { capabilities: {} }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + for (const url of ["http://evil.com", "javascript:alert(1)", "file:///etc/passwd"]) { + const res = await client.callTool({ name: "set_app_frame", arguments: { url } }); + const out = (res.content as { text: string }[])[0].text; + expect(out).toContain("rejected"); + // The rejected URL never updates state: no beat is emitted, so it stays null. + expect(bridge.state.appFrameUrl).toBeNull(); + } + await client.close(); + await server.close(); + }); + + it("ask_question resolves from a native free-text elicitation", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const client = new Client({ name: "t", version: "0.0.1" }, { capabilities: { elicitation: {} } }); + client.setRequestHandler(ElicitRequestSchema, async () => ({ action: "accept", content: { answer: "the goal" } })); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([server.connect(st), client.connect(ct)]); + const res = await client.callTool({ name: "ask_question", arguments: { prompt: "What is the goal?" } }); + expect((res.content as { text: string }[])[0].text).toBe("the goal"); + await client.close(); + await server.close(); + }); + + it("present_options forwards the id into the decisions flow", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await server.connect(serverT); + const client = new Client({ name: "test", version: "0" }); + await client.connect(clientT); + + void client.callTool({ name: "present_options", arguments: { prompt: "Pick?", options: [{ id: "a", title: "A" }], id: "d7" } }); + await new Promise((r) => setTimeout(r, 10)); + expect(bridge.state.decisions.find((d) => d.id === "d7")?.kind).toBe("choice"); + }); + + it("dataset_profile + add_column drive the data profile state", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await server.connect(serverT); + const client = new Client({ name: "test", version: "0" }); + await client.connect(clientT); + + await client.callTool({ name: "dataset_profile", arguments: { name: "sales.csv", rows: 100, columns: 2, source: "dw" } }); + await client.callTool({ name: "add_column", arguments: { name: "id", dtype: "int", nullPct: 0, distinct: 100, quality: "ok" } }); + expect(bridge.state.domain).toBe("dataprofile"); + expect(bridge.state.profileDataset).toMatchObject({ name: "sales.csv", cols: 2 }); + expect(bridge.state.profileColumns[0]).toMatchObject({ name: "id", dtype: "int", quality: "ok" }); + + await client.close(); + await server.close(); + }); + + it("set_steps drives the interview program state", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await server.connect(serverT); + const client = new Client({ name: "test", version: "0" }); + await client.connect(clientT); + await client.callTool({ name: "set_steps", arguments: { steps: ["Frame", "Decide", "Govern"], current: 1, label: "ADR" } }); + expect(bridge.state.interviewSteps).toEqual({ label: "ADR", names: ["Frame", "Decide", "Govern"], current: 1 }); + }); + + it("set_steps forwards progress", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await server.connect(serverT); + const client = new Client({ name: "test", version: "0" }); + await client.connect(clientT); + await client.callTool({ name: "set_steps", arguments: { steps: ["a", "b"], current: 0, progress: { done: 1, total: 4 } } }); + expect(bridge.state.interviewSteps!.progress).toEqual({ done: 1, total: 4 }); + }); + + it("gallery tools drive the gallery state and reject bad input", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await server.connect(serverT); + const client = new Client({ name: "test", version: "0" }); + await client.connect(clientT); + + await client.callTool({ name: "gallery_open", arguments: { title: "Apps", size: "l", items: [ + { label: "Local", url: "http://localhost:3000/" }, + { label: "Snap", html: "x" }, + ] } }); + expect(bridge.state.domain).toBe("gallery"); + expect(bridge.state.galleryTitle).toBe("Apps"); + expect(bridge.state.gallerySize).toBe("l"); + expect(bridge.state.galleryItems.map((i) => i.id)).toEqual(["g0", "g1"]); + + await client.callTool({ name: "gallery_add", arguments: { item: { id: "x", label: "Ext", url: "https://example.com" } } }); + expect(bridge.state.galleryItems.at(-1)).toMatchObject({ id: "x", label: "Ext" }); + + await client.callTool({ name: "gallery_clear", arguments: {} }); + expect(bridge.state.galleryItems).toEqual([]); + + const bad = await client.callTool({ name: "gallery_open", arguments: { title: "T", items: [{ label: "Bad", url: "http://evil.example.com" }] } }); + expect(bad.isError).toBe(true); + }); + + it("promptlab_start + add_case drive the workbench and reject a bad verdict", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await server.connect(serverT); + const client = new Client({ name: "test", version: "0" }); + await client.connect(clientT); + + await client.callTool({ name: "promptlab_start", arguments: { name: "summarizer.prompt", prompt: "You are…", round: 2 } }); + await client.callTool({ name: "add_case", arguments: { id: "c1", scenario: "empty input", output: "(nothing)", verdict: "fail", note: "no guard" } }); + expect(bridge.state.domain).toBe("promptlab"); + expect(bridge.state.promptName).toBe("summarizer.prompt"); + expect(bridge.state.promptRound).toBe(2); + expect(bridge.state.promptCases[0]).toMatchObject({ id: "c1", scenario: "empty input", verdict: "fail" }); + + const bad = await client.callTool({ name: "add_case", arguments: { id: "c2", scenario: "x", verdict: "bogus" } }); + expect(bad.isError).toBe(true); + }); + + it("flow tools drive the canvas and reject bad enums", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await server.connect(serverT); + const client = new Client({ name: "test", version: "0" }); + await client.connect(clientT); + + await client.callTool({ name: "flow_open", arguments: { title: "hve-core pipeline" } }); + await client.callTool({ name: "add_flow_node", arguments: { id: "triage", kind: "workflow", label: "Issue Triage", sub: "copilot" } }); + await client.callTool({ name: "add_flow_node", arguments: { id: "impl", kind: "workflow", label: "Implement" } }); + await client.callTool({ name: "add_flow_edge", arguments: { id: "e1", from: "triage", to: "impl", label: "agent-ready" } }); + await client.callTool({ name: "flow_focus", arguments: { workflow: "triage" } }); + expect(bridge.state.domain).toBe("flow"); + expect(bridge.state.flowTitle).toBe("hve-core pipeline"); + expect(bridge.state.flowNodes.map((n) => n.id)).toEqual(["triage", "impl"]); + expect(bridge.state.flowEdges[0]).toMatchObject({ from: "triage", to: "impl", label: "agent-ready", kind: "label" }); + expect(bridge.state.flowFocus).toBe("triage"); + + const badKind = await client.callTool({ name: "add_flow_node", arguments: { id: "x", kind: "bogus", label: "x" } }); + expect(badKind.isError).toBe(true); + const badStatus = await client.callTool({ name: "add_flow_node", arguments: { id: "y", kind: "workflow", label: "y", status: "bogus" } }); + expect(badStatus.isError).toBe(true); + const badEdgeKind = await client.callTool({ name: "add_flow_edge", arguments: { id: "z", from: "triage", to: "impl", kind: "bogus" } }); + expect(badEdgeKind.isError).toBe(true); +}); + + it("memory tools drive the memory view and reject bad enums", async () => { + const bridge = new Bridge(); + const server = buildMcpServer(bridge); + const [clientT, serverT] = InMemoryTransport.createLinkedPair(); + await server.connect(serverT); + const client = new Client({ name: "test", version: "0" }); + await client.connect(clientT); + + await client.callTool({ name: "memory_open", arguments: { title: "hve-core" } }); + await client.callTool({ name: "add_memory", arguments: { id: "e1", content: "likes terse output", category: "user", tag: "recalled" } }); + await client.callTool({ name: "add_handoff", arguments: { id: "h1", from: "GitHub Backlog Manager", summary: "sprint state", action: "stored" } }); + expect(bridge.state.domain).toBe("memory"); + expect(bridge.state.memoryTitle).toBe("hve-core"); + expect(bridge.state.memoryEntries[0]).toMatchObject({ id: "e1", category: "user", tag: "recalled" }); + expect(bridge.state.memoryHandoffs[0]).toMatchObject({ id: "h1", from: "GitHub Backlog Manager", action: "stored" }); + + const badTag = await client.callTool({ name: "add_memory", arguments: { id: "e2", content: "x", category: "user", tag: "bogus" } }); + expect(badTag.isError).toBe(true); + const badAction = await client.callTool({ name: "add_handoff", arguments: { id: "h2", from: "x", summary: "y", action: "bogus" } }); + expect(badAction.isError).toBe(true); + }); +}); diff --git a/rpi-cockpit/tests/memory-client.test.ts b/rpi-cockpit/tests/memory-client.test.ts new file mode 100644 index 000000000..60f2fc938 --- /dev/null +++ b/rpi-cockpit/tests/memory-client.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Window } from "happy-dom"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { initialState, applyBeat } from "../src/state.js"; +import { toViewModel } from "../src/render.js"; + +const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "public"); +function boot() { + const html = readFileSync(path.join(PUBLIC, "index.html"), "utf8"); + const js = readFileSync(path.join(PUBLIC, "client.js"), "utf8"); + const win = new Window({ url: "http://127.0.0.1:4399/" }); + win.document.write(html); + (win as any).WebSocket = class { readyState = 1; send() {} close() {} }; + win.eval(js.replace(/^import .*$/gm, "")); + return win; +} +function memVm() { + let s = applyBeat(initialState(), { type: "memory.open", title: "hve-core" }, 1); + s = applyBeat(s, { type: "memory.add", id: "e1", title: "output style", content: "Prefers terse output, no preamble.", category: "user", tag: "recalled" }, 2); + s = applyBeat(s, { type: "memory.add", id: "e2", content: "Ship to the fork, never origin.", category: "project", tag: "added" }, 3); + s = applyBeat(s, { type: "handoff.add", id: "h1", from: "GitHub Backlog Manager", summary: "Sprint 24 state", action: "stored" }, 4); + return toViewModel(s); +} + +describe("memory client", () => { + let win: ReturnType; + beforeEach(() => { win = boot(); }); + + it("shows the memory view and hides the others on the memory domain", () => { + (win as any).render(memVm()); + expect((win.document.getElementById("memory-view") as any).hidden).toBe(false); + expect((win.document.getElementById("rpi-view") as any).hidden).toBe(true); + expect((win.document.getElementById("promptlab-view") as any).hidden).toBe(true); + }); + + it("renders entries grouped by category with tag pills, a handoff card, and the counts", () => { + (win as any).render(memVm()); + expect(win.document.querySelectorAll("#me-entries .me-group").length).toBe(2); + expect(win.document.querySelectorAll("#me-entries .me-entry").length).toBe(2); + expect(win.document.querySelector("#me-entries .me-t-recalled")).not.toBeNull(); + expect(win.document.querySelector("#me-entries .me-t-added")).not.toBeNull(); + expect(win.document.querySelectorAll("#me-handoffs .mh-card").length).toBe(1); + expect((win.document.getElementById("me-title") as any).textContent).toContain("hve-core"); + }); + + it("expands an entry on click to reveal the full content", () => { + (win as any).render(memVm()); + const head = win.document.querySelector("#me-entries .me-entry .me-head") as any; + head.dispatchEvent(new win.Event("click", { bubbles: true })); + expect((win.document.querySelector("#me-entries .me-entry") as any).className).toContain("open"); + }); +}); diff --git a/rpi-cockpit/tests/navigator-client.test.ts b/rpi-cockpit/tests/navigator-client.test.ts new file mode 100644 index 000000000..339f6ad2d --- /dev/null +++ b/rpi-cockpit/tests/navigator-client.test.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Window } from "happy-dom"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { initialState, startLaunch, setNavigatorOpen } from "../src/state.js"; +import { toViewModel } from "../src/render.js"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const PUBLIC = path.join(here, "..", "public"); + +function boot() { + const html = readFileSync(path.join(PUBLIC, "index.html"), "utf8"); + const js = readFileSync(path.join(PUBLIC, "client.js"), "utf8"); + const win = new Window({ url: "http://127.0.0.1:4399/" }); + win.document.write(html); + const sent: any[] = []; + // Stub the WebSocket so client.js can construct one; capture sent frames. + (win as any).WebSocket = class { + readyState = 1; onopen: any; onclose: any; onerror: any; onmessage: any; + constructor() { /* no-op */ } + send(s: string) { sent.push(JSON.parse(s)); } + close() {} + }; + // Execute the client module body in the window context. + // Strip ES module import lines (happy-dom eval doesn't resolve bare specifiers). + win.eval(js.replace(/^import .*$/gm, "")); + return { win, sent }; +} + +describe("navigator client", () => { + let env: ReturnType; + beforeEach(() => { env = boot(); }); + + it("shows the home with the orient strip; the loop view stays out", () => { + const view = toViewModel(initialState()); + (env.win as any).render(view); + const doc = env.win.document; + expect((doc.getElementById("home") as any).hidden).toBe(false); + expect((doc.getElementById("loop") as any).hidden).toBe(true); + // The orient strip is present; the launcher tiles live in the Navigator pop-up. + expect(doc.getElementById("orient")).not.toBeNull(); + }); + + it("shows the loop screen when the view is loop", () => { + const view = toViewModel(startLaunch(initialState(), "build")); + (env.win as any).render(view); + expect((env.win.document.getElementById("home") as any).hidden).toBe(true); + expect((env.win.document.getElementById("loop") as any).hidden).toBe(false); + }); + + it("renders the six workflow tiles into the Navigator pop-up", () => { + (env.win as any).render(toViewModel(initialState())); + const tiles = env.win.document.querySelectorAll("#nav-workflows [data-launch]"); + expect(tiles.length).toBe(6); + expect(Array.from(tiles).map((t: any) => t.dataset.launch)) + .toEqual(["build", "review", "plan", "docs", "data", "coach"]); + }); + + it("launches a workflow and closes the pop-up when a tile is clicked", () => { + (env.win as any).render(toViewModel(initialState())); + const doc = env.win.document; + const tile = doc.querySelector('#nav-workflows [data-launch="review"]') as any; + tile.click(); + expect(env.sent).toContainEqual({ type: "launch", workflowId: "review" }); + expect((doc.getElementById("welcome") as any).hidden).toBe(true); + }); + + it("opens the pop-up when the view-model has navigatorOpen true", () => { + (env.win as any).render(toViewModel(setNavigatorOpen(initialState(), true))); + expect((env.win.document.getElementById("welcome") as any).hidden).toBe(false); + }); + + it("a dismiss sends a navigator-close frame and stays hidden across a re-render", () => { + // open_navigator opens the overlay; the dismiss must flow back to the server + // (frame) AND survive the next state push with navigatorOpen:false. (A2) + (env.win as any).render(toViewModel(setNavigatorOpen(initialState(), true))); + const doc = env.win.document; + expect((doc.getElementById("welcome") as any).hidden).toBe(false); + (doc.getElementById("welcome-dismiss") as any).click(); + expect(env.sent).toContainEqual({ type: "navigator", open: false }); + expect((doc.getElementById("welcome") as any).hidden).toBe(true); + // The server has now cleared the flag; the next broadcast must NOT reopen it. + (env.win as any).render(toViewModel(setNavigatorOpen(initialState(), false))); + expect((doc.getElementById("welcome") as any).hidden).toBe(true); + }); + + it("Enter on a focused workflow tile launches it and closes the pop-up", () => { + // Keyboard parity with a click: the tile is role=button/tabindex=0. (C1) + (env.win as any).render(toViewModel(setNavigatorOpen(initialState(), true))); + const doc = env.win.document; + const tile = doc.querySelector('#nav-workflows [data-launch="plan"]') as any; + const ev = new (env.win as any).KeyboardEvent("keydown", { key: "Enter", bubbles: true }); + tile.dispatchEvent(ev); + expect(env.sent).toContainEqual({ type: "launch", workflowId: "plan" }); + expect(env.sent).toContainEqual({ type: "navigator", open: false }); + expect((doc.getElementById("welcome") as any).hidden).toBe(true); + }); +}); diff --git a/rpi-cockpit/tests/port.test.ts b/rpi-cockpit/tests/port.test.ts new file mode 100644 index 000000000..f17b2b760 --- /dev/null +++ b/rpi-cockpit/tests/port.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { resolvePort } from "../src/port.js"; + +describe("resolvePort", () => { + it("uses PORT when it is a valid port", () => { + expect(resolvePort({ PORT: "5123" })).toBe(5123); + }); + + it("prefers PORT over RPI_COCKPIT_PORT", () => { + expect(resolvePort({ PORT: "5123", RPI_COCKPIT_PORT: "6001" })).toBe(5123); + }); + + it("falls back to RPI_COCKPIT_PORT when PORT is absent", () => { + expect(resolvePort({ RPI_COCKPIT_PORT: "6001" })).toBe(6001); + }); + + it("falls back to 4399 when neither is set", () => { + expect(resolvePort({})).toBe(4399); + }); + + it("treats an empty PORT as absent and falls through", () => { + expect(resolvePort({ PORT: "", RPI_COCKPIT_PORT: "6001" })).toBe(6001); + }); + + it("treats a non-numeric PORT as absent and falls through", () => { + expect(resolvePort({ PORT: "not-a-port", RPI_COCKPIT_PORT: "6001" })).toBe(6001); + }); + + it("treats PORT=0 as absent and falls through", () => { + expect(resolvePort({ PORT: "0", RPI_COCKPIT_PORT: "6001" })).toBe(6001); + }); + + it("rejects an out-of-range PORT and falls through to the default", () => { + expect(resolvePort({ PORT: "70000" })).toBe(4399); + }); + + it("rejects a fractional PORT and falls through to the default", () => { + expect(resolvePort({ PORT: "8080.5" })).toBe(4399); + }); +}); diff --git a/rpi-cockpit/tests/promptlab-client.test.ts b/rpi-cockpit/tests/promptlab-client.test.ts new file mode 100644 index 000000000..bc5a9e037 --- /dev/null +++ b/rpi-cockpit/tests/promptlab-client.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Window } from "happy-dom"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { initialState, applyBeat } from "../src/state.js"; +import { toViewModel } from "../src/render.js"; + +const PUBLIC = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "public"); +function boot() { + const html = readFileSync(path.join(PUBLIC, "index.html"), "utf8"); + const js = readFileSync(path.join(PUBLIC, "client.js"), "utf8"); + const win = new Window({ url: "http://127.0.0.1:4399/" }); + win.document.write(html); + (win as any).WebSocket = class { readyState = 1; send() {} close() {} }; + win.eval(js.replace(/^import .*$/gm, "")); + return win; +} +function benchVm() { + let s = applyBeat(initialState(), { type: "promptlab.start", name: "summarizer.prompt", prompt: "You are a summarizer.", round: 2 }, 1); + s = applyBeat(s, { type: "case.add", id: "c1", scenario: "empty input", output: "(produced nothing)", verdict: "fail", note: "no empty-input guard" }, 2); + s = applyBeat(s, { type: "case.add", id: "c2", scenario: "long input", output: "ok summary", verdict: "pass" }, 3); + return toViewModel(s); +} + +describe("promptlab client", () => { + let win: ReturnType; + beforeEach(() => { win = boot(); }); + + it("shows the promptlab view and hides the others on the promptlab domain", () => { + (win as any).render(benchVm()); + expect((win.document.getElementById("promptlab-view") as any).hidden).toBe(false); + expect((win.document.getElementById("rpi-view") as any).hidden).toBe(true); + expect((win.document.getElementById("gallery-view") as any).hidden).toBe(true); + }); + + it("renders one case per scenario with a verdict pill, the prompt panel, and the summary", () => { + (win as any).render(benchVm()); + const cases = win.document.querySelectorAll("#pl-cases .pc-case"); + expect(cases.length).toBe(2); + expect(win.document.querySelector("#pl-cases .pc-v-fail")).not.toBeNull(); + expect(win.document.querySelector("#pl-cases .pc-v-pass")).not.toBeNull(); + expect((win.document.getElementById("pl-name") as any).textContent).toContain("summarizer.prompt"); + expect((win.document.getElementById("pl-prompt") as any).textContent).toContain("You are a summarizer."); + }); + + it("expands a case on click to reveal the full output", () => { + (win as any).render(benchVm()); + const head = win.document.querySelector("#pl-cases .pc-case .pc-head") as any; + head.dispatchEvent(new win.Event("click", { bubbles: true })); + expect((win.document.querySelector("#pl-cases .pc-case") as any).className).toContain("open"); + }); +}); diff --git a/rpi-cockpit/tests/render.test.ts b/rpi-cockpit/tests/render.test.ts new file mode 100644 index 000000000..f1bd8242f --- /dev/null +++ b/rpi-cockpit/tests/render.test.ts @@ -0,0 +1,366 @@ +// rpi-cockpit/tests/render.test.ts +import { describe, it, expect } from "vitest"; +import { toViewModel } from "../src/render.js"; +import { initialState, applyBeat, enqueueDirective, startLaunch, addDecision, answerDecision, setHostElicits } from "../src/state.js"; + +describe("toViewModel", () => { + it("marks the current phase active and prior phases done", () => { + let s = applyBeat(initialState(), { type: "phase.enter", phase: "research" }, 1); + s = applyBeat(s, { type: "phase.enter", phase: "implement" }, 2); + const vm = toViewModel(s); + expect(vm.steps.find((x) => x.phase === "research")!.status).toBe("done"); + expect(vm.steps.find((x) => x.phase === "implement")!.status).toBe("active"); + expect(vm.steps.find((x) => x.phase === "review")!.status).toBe("pending"); + }); + it("exposes validations as check/status pairs", () => { + const s = applyBeat(initialState(), { type: "validate", check: "tests", status: "fail" }, 1); + expect(toViewModel(s).validations).toContainEqual({ check: "tests", status: "fail" }); + }); + + it("is not started and shows the waiting lead before a session begins", () => { + const vm = toViewModel(initialState()); + expect(vm.started).toBe(false); + expect(vm.phaseNumber).toBeNull(); + expect(vm.lead).toMatch(/Waiting for an RPI session/); + }); + + it("is started for a directly-launched domain with no session.begin", () => { + // review/interview/backlog set domain without task/phase; started must be true + // so the Home orient strip does not claim "Nothing running" mid-session. (B1) + for (const beat of [ + { type: "review.start", target: "x" }, + { type: "interview.start", docType: "PRD" }, + { type: "backlog.start", target: "S", columns: ["Todo"] }, + ] as const) { + const s = applyBeat(initialState(), beat, 1); + expect(toViewModel(s).started).toBe(true); + } + }); + + it("derives phase label, number, and lead from the active phase", () => { + const s = applyBeat(initialState(), { type: "phase.enter", phase: "implement" }, 1); + const vm = toViewModel(s); + expect(vm.started).toBe(true); + expect(vm.phaseLabel).toBe("Implement"); + expect(vm.phaseNumber).toBe(3); + expect(vm.lead).toMatch(/Executing the plan/); + }); + + it("falls back to preset steer options when the agent has not offered a menu", () => { + const vm = toViewModel(initialState()); + expect(vm.steerMenu.source).toBe("preset"); + expect(vm.steerMenu.options.map((o) => o.id)).toEqual(["default", "thorough", "faster", "ask-first"]); + }); + + it("uses the agent-declared menu when offered", () => { + const s = applyBeat(initialState(), { type: "approaches.offer", label: "Implementor", options: [{ id: "x", title: "X" }] }, 1); + const vm = toViewModel(s); + expect(vm.steerMenu.source).toBe("agent"); + expect(vm.steerMenu.options).toEqual([{ id: "x", title: "X", detail: undefined }]); + }); + + it("exposes queued directives", () => { + const s = enqueueDirective(initialState(), { id: "s1", kind: "note", text: "focus" }, 1); + expect(toViewModel(s).directives).toHaveLength(1); + }); + + it("surfaces the agent screen, defaulting to null", () => { + expect(toViewModel(initialState()).screen).toBeNull(); + const s = applyBeat(initialState(), { type: "screen.show", html: "

hi

", title: "Mockup" }, 1); + expect(toViewModel(s).screen).toEqual({ html: "

hi

", title: "Mockup" }); + }); + + it("exposes docType", () => { + const s = applyBeat(initialState(), { type: "interview.start", docType: "ADR" }, 1); + const vm = toViewModel(s); + expect(vm.domain).toBe("interview"); + expect(vm.docType).toBe("ADR"); + }); + + it("projects the decisions flow and hostElicits, and drops the legacy single-decision fields", () => { + let s = initialState(); + s = setHostElicits(s, true); + s = answerDecision(addDecision(s, { id: "d1", prompt: "Pick?", kind: "choice", options: [{ id: "a", title: "A" }] }), "d1", "a"); + s = addDecision(s, { id: "q2", prompt: "Name?", kind: "text" }); + const vm = toViewModel(s); + expect(vm.hostElicits).toBe(true); + expect(vm.decisions).toHaveLength(2); + expect(vm.decisions[0]).toMatchObject({ id: "d1", kind: "choice", status: "answered", answer: "a" }); + expect(vm.decisions[1]).toMatchObject({ id: "q2", kind: "text", status: "pending" }); + expect("decision" in vm).toBe(false); + expect("pendingQuestion" in vm).toBe(false); + }); + + describe("findings view-model", () => { + it("exposes the domain and review target", () => { + let s = applyBeat(initialState(), { type: "review.start", target: "PR 9" }, 1); + const vm = toViewModel(s); + expect(vm.domain).toBe("review"); + expect(vm.reviewTarget).toBe("PR 9"); + }); + it("groups findings by severity in critical-first order, only non-empty groups", () => { + let s = applyBeat(initialState(), { type: "review.start", target: "x" }, 1); + s = applyBeat(s, { type: "finding.add", severity: "low", title: "L" }, 2); + s = applyBeat(s, { type: "finding.add", severity: "critical", title: "C" }, 3); + s = applyBeat(s, { type: "finding.add", severity: "low", title: "L2" }, 4); + const groups = toViewModel(s).findingGroups; + expect(groups.map((g) => g.severity)).toEqual(["critical", "low"]); + expect(groups[0].items.map((i) => i.title)).toEqual(["C"]); + expect(groups[1].items.map((i) => i.title)).toEqual(["L", "L2"]); + }); + }); + + describe("board view-model", () => { + it("returns columns in declared order, keeps empty columns, groups items, with count and passthrough", () => { + let s = applyBeat(initialState(), { type: "backlog.start", target: "Sprint 4", columns: ["Todo", "Doing", "Done"] }, 1); + s = applyBeat(s, { type: "item.add", id: "I1", title: "a", column: "Todo", kind: "bug", tier: "T1" }, 2); + s = applyBeat(s, { type: "item.add", id: "I2", title: "b", column: "Done" }, 3); + s = applyBeat(s, { type: "item.add", id: "I3", title: "c", column: "Todo" }, 4); + s = applyBeat(s, { type: "backlog.action", text: "triaging" }, 5); + const { board } = toViewModel(s); + expect(board.target).toBe("Sprint 4"); + expect(board.action).toBe("triaging"); + expect(board.count).toBe(3); + expect(board.columns.map((c) => c.name)).toEqual(["Todo", "Doing", "Done"]); + expect(board.columns[1].items).toEqual([]); + expect(board.columns[0].items.map((i) => i.id)).toEqual(["I1", "I3"]); + expect(board.columns[0].items[0]).toEqual({ id: "I1", title: "a", kind: "bug", tier: "T1", depth: 0 }); + expect(board.columns[2].items.map((i) => i.id)).toEqual(["I2"]); + }); + }); + + describe("team view-model", () => { + it("groups agents by status in fixed order, drops empty status columns, count = total", () => { + let s = applyBeat(initialState(), { type: "team.start", task: "ship", orchestrator: "Lead" }, 1); + s = applyBeat(s, { type: "agent.add", id: "a1", name: "Q", status: "queued" }, 2); + s = applyBeat(s, { type: "agent.add", id: "a2", name: "R1", role: "impl", status: "running" }, 3); + s = applyBeat(s, { type: "agent.add", id: "a3", name: "R2", status: "running" }, 4); + s = applyBeat(s, { type: "agent.update", id: "a2", action: "writing tests" }, 5); + const { team } = toViewModel(s); + expect(team.orchestrator).toBe("Lead"); + expect(team.count).toBe(3); + // running comes before queued; blocked/done/failed are empty and dropped + expect(team.columns.map((c) => c.status)).toEqual(["running", "queued"]); + expect(team.columns[0].label).toBe("Running"); + expect(team.columns[0].agents.map((a) => a.id)).toEqual(["a2", "a3"]); + expect(team.columns[0].agents[0]).toEqual({ id: "a2", name: "R1", role: "impl", action: "writing tests" }); + expect(team.columns[1].agents.map((a) => a.id)).toEqual(["a1"]); + }); + it("defaults to a null orchestrator, zero count, and no columns", () => { + const { team } = toViewModel(initialState()); + expect(team).toEqual({ orchestrator: null, count: 0, columns: [] }); + }); + }); + + describe("codemap view-model", () => { + it("passes through nodes, focus, and touches", () => { + let s = applyBeat(initialState(), { type: "codemap.set", nodes: [ + { id: "n1", path: "src/a.ts", kind: "file" }, + { id: "n2", path: "src/b.ts", kind: "file" }, + ] }, 1); + s = applyBeat(s, { type: "codemap.focus", id: "n1" }, 2); + s = applyBeat(s, { type: "codemap.touch", id: "n2", kind: "edit" }, 3); + const vm = toViewModel(s); + expect(vm.domain).toBe("codemap"); + expect(vm.codemap.nodes.map((n) => n.id)).toEqual(["n1", "n2"]); + expect(vm.codemap.focus).toBe("n1"); + expect(vm.codemap.touches).toEqual({ n2: "edit" }); + }); + it("derives the group from the path top segment when group is absent", () => { + const s = applyBeat(initialState(), { type: "codemap.set", nodes: [ + { id: "n1", path: "src/a.ts", kind: "file" }, + { id: "n2", path: "README.md", kind: "file" }, + ] }, 1); + const vm = toViewModel(s); + expect(vm.codemap.nodes[0].group).toBe("src"); + expect(vm.codemap.nodes[1].group).toBe("(root)"); + }); + it("keeps an explicit group over the derived one", () => { + const s = applyBeat(initialState(), { type: "codemap.set", nodes: [ + { id: "n1", path: "src/a.ts", kind: "file", group: "core" }, + ] }, 1); + expect(toViewModel(s).codemap.nodes[0].group).toBe("core"); + }); + it("defaults to empty nodes, null focus, empty touches", () => { + expect(toViewModel(initialState()).codemap).toEqual({ nodes: [], focus: null, touches: {} }); + }); + }); + + describe("context view-model", () => { + it("passes through instructions, skills, and collection", () => { + const s = applyBeat(initialState(), { type: "context.set", instructions: ["no em-dashes"], skills: ["tdd", "deepsearch"], collection: "hve-core" }, 1); + const vm = toViewModel(s); + expect(vm.context.instructions).toEqual(["no em-dashes"]); + expect(vm.context.skills).toEqual(["tdd", "deepsearch"]); + expect(vm.context.collection).toBe("hve-core"); + }); + it("defaults to empty lists and null collection", () => { + const vm = toViewModel(initialState()); + expect(vm.context).toEqual({ instructions: [], skills: [], collection: null }); + }); + }); + + describe("app frame view-model", () => { + it("passes through the app frame url", () => { + const s = applyBeat(initialState(), { type: "appframe.set", url: "http://localhost:5173" }, 1); + expect(toViewModel(s).appFrame.url).toBe("http://localhost:5173"); + }); + it("defaults the app frame url to null", () => { + expect(toViewModel(initialState()).appFrame).toEqual({ url: null }); + }); + }); + + describe("backlog hierarchy projection", () => { + function build(items: { id: string; title: string; column: string; parent?: string }[]) { + let s = applyBeat(initialState(), { type: "backlog.start", target: "S", columns: ["Plan", "Done"] }, 1); + items.forEach((it, n) => { s = applyBeat(s, { type: "item.add", ...it }, n + 2); }); + return toViewModel(s); + } + const plan = (vm: any) => vm.board.columns.find((c: any) => c.name === "Plan").items; + const done = (vm: any) => vm.board.columns.find((c: any) => c.name === "Done").items; + + it("nests a same-column chain with increasing depth, parent-first", () => { + const vm = build([ + { id: "E", title: "Epic", column: "Plan" }, + { id: "F", title: "Feature", column: "Plan", parent: "E" }, + { id: "S", title: "Story", column: "Plan", parent: "F" }, + ]); + expect(plan(vm).map((i: any) => [i.id, i.depth])).toEqual([["E", 0], ["F", 1], ["S", 2]]); + expect(plan(vm).every((i: any) => i.parentRef === undefined)).toBe(true); + }); + + it("shows a parentRef when the parent is in a different column", () => { + const vm = build([ + { id: "E", title: "Epic", column: "Plan" }, + { id: "S", title: "Story", column: "Done", parent: "E" }, + ]); + expect(done(vm)).toEqual([{ id: "S", title: "Story", kind: undefined, tier: undefined, depth: 0, parentRef: "Epic" }]); + }); + + it("falls back to the raw parent id when the parent is not on the board", () => { + const vm = build([{ id: "S", title: "Orphan", column: "Plan", parent: "ghost" }]); + expect(plan(vm)[0]).toMatchObject({ id: "S", depth: 0, parentRef: "ghost" }); + }); + + it("keeps parentless items in insertion order at depth 0", () => { + const vm = build([ + { id: "A", title: "A", column: "Plan" }, + { id: "B", title: "B", column: "Plan" }, + ]); + expect(plan(vm).map((i: any) => [i.id, i.depth])).toEqual([["A", 0], ["B", 0]]); + }); + }); + + it("projects the gallery with derived kind and src", () => { + let s = applyBeat(initialState(), { type: "gallery.open", title: "G", size: "s", items: [ + { id: "u", label: "U", group: "live", url: "https://example.com" }, + { id: "h", label: "H", html: "x" }, + { id: "e", label: "E" }, + ] }, 1); + const vm = toViewModel(s); + expect(vm.domain).toBe("gallery"); + expect(vm.gallery.title).toBe("G"); + expect(vm.gallery.size).toBe("s"); + expect(vm.gallery.items).toEqual([ + { id: "u", label: "U", group: "live", kind: "url", src: "https://example.com", caption: null }, + { id: "h", label: "H", group: null, kind: "html", src: "x", caption: null }, + { id: "e", label: "E", group: null, kind: "empty", src: null, caption: null }, + ]); + expect(toViewModel(initialState()).gallery.title).toBeNull(); + expect(toViewModel(initialState()).gallery.size).toBe("m"); + }); + + it("projects the promptlab bench with a derived verdict summary", () => { + let s = applyBeat(initialState(), { type: "promptlab.start", name: "p", prompt: "txt", round: 3 }, 1); + s = applyBeat(s, { type: "case.add", id: "a", scenario: "s1", output: "o1", verdict: "pass" }, 2); + s = applyBeat(s, { type: "case.add", id: "b", scenario: "s2", verdict: "fail", note: "bad" }, 3); + s = applyBeat(s, { type: "case.add", id: "c", scenario: "s3" }, 4); + const vm = toViewModel(s); + expect(vm.domain).toBe("promptlab"); + expect(vm.promptlab.name).toBe("p"); + expect(vm.promptlab.round).toBe(3); + expect(vm.promptlab.prompt).toBe("txt"); + expect(vm.promptlab.summary).toEqual({ pass: 1, warn: 0, fail: 1, pending: 1, running: 0, total: 3 }); + expect(vm.promptlab.cases[1]).toEqual({ id: "b", scenario: "s2", output: null, verdict: "fail", note: "bad" }); + expect(toViewModel(initialState()).promptlab.name).toBeNull(); + }); + + it("projects the memory view with derived tag counts", () => { + let s = applyBeat(initialState(), { type: "memory.open", title: "hve-core" }, 1); + s = applyBeat(s, { type: "memory.add", id: "a", content: "c1", category: "user", tag: "recalled" }, 2); + s = applyBeat(s, { type: "memory.add", id: "b", content: "c2", category: "project", tag: "added" }, 3); + s = applyBeat(s, { type: "memory.add", id: "c", content: "c3", category: "project" }, 4); + s = applyBeat(s, { type: "handoff.add", id: "h", from: "RPI", summary: "did x", action: "stored" }, 5); + const vm = toViewModel(s); + expect(vm.domain).toBe("memory"); + expect(vm.memory.title).toBe("hve-core"); + expect(vm.memory.counts).toEqual({ recalled: 2, added: 1, updated: 0, total: 3 }); + expect(vm.memory.entries[1]).toEqual({ id: "b", title: null, content: "c2", category: "project", tag: "added" }); + expect(vm.memory.handoffs[0]).toEqual({ id: "h", from: "RPI", summary: "did x", action: "stored" }); + expect(toViewModel(initialState()).memory.title).toBeNull(); + }); + + it("projects the flow graph as pure data", () => { + let s = applyBeat(initialState(), { type: "flow.open", title: "p" }, 1); + s = applyBeat(s, { type: "flownode.add", id: "a", kind: "workflow", label: "Triage" }, 2); + s = applyBeat(s, { type: "flownode.add", id: "t", kind: "trigger", label: "issue", scope: "a" }, 3); + s = applyBeat(s, { type: "flowedge.add", id: "e", from: "a", to: "a", label: "self", kind: "event", status: "active" }, 4); + s = applyBeat(s, { type: "flow.focus", workflow: "a" }, 5); + const vm = toViewModel(s); + expect(vm.domain).toBe("flow"); + expect(vm.flow.title).toBe("p"); + expect(vm.flow.focus).toBe("a"); + expect(vm.flow.nodes[0]).toEqual({ id: "a", scope: "orchestration", kind: "workflow", label: "Triage", sub: null, status: "idle" }); + expect(vm.flow.nodes[1]).toMatchObject({ id: "t", scope: "a", kind: "trigger" }); + expect(vm.flow.edges[0]).toEqual({ id: "e", from: "a", to: "a", scope: "orchestration", label: "self", kind: "event", status: "active" }); + expect(toViewModel(initialState()).flow.title).toBeNull(); + }); + + it("projects the data profile dataset and columns", () => { + let s = applyBeat(initialState(), { type: "profile.start", name: "sales.csv", rows: 100, columns: 3, source: "warehouse" }, 1); + s = applyBeat(s, { type: "column.add", name: "id", dtype: "int", nullPct: 0, distinct: 100, quality: "ok" }, 2); + const vm = toViewModel(s); + expect(vm.domain).toBe("dataprofile"); + expect(vm.dataProfile.dataset).toEqual({ name: "sales.csv", rows: 100, cols: 3, source: "warehouse" }); + expect(vm.dataProfile.columns).toEqual([{ name: "id", dtype: "int", nullPct: 0, distinct: 100, stat: undefined, quality: "ok" }]); + expect(toViewModel(initialState()).dataProfile.dataset).toBeNull(); + }); + + it("attaches progress to the active step only", () => { + const s = applyBeat(initialState(), { type: "steps.set", steps: ["Frame", "Decide", "Govern"], current: 1, progress: { done: 2, total: 3 } }, 1); + const steps = toViewModel(s).interviewSteps!.steps; + expect(steps[1]).toEqual({ name: "Decide", status: "active", progress: { done: 2, total: 3 } }); + expect((steps[0] as any).progress).toBeUndefined(); + expect((steps[2] as any).progress).toBeUndefined(); + }); + + it("projects interview steps with done/active/pending derived from current", () => { + const s = applyBeat(initialState(), { type: "steps.set", steps: ["Frame", "Decide", "Govern"], current: 1, label: "ADR" }, 1); + const vm = toViewModel(s); + expect(vm.interviewSteps).toEqual({ label: "ADR", steps: [ + { name: "Frame", status: "done" }, + { name: "Decide", status: "active" }, + { name: "Govern", status: "pending" }, + ] }); + expect(toViewModel(initialState()).interviewSteps).toBeNull(); + }); + + describe("navigator fields", () => { + it("exposes the view and the workflow catalog", () => { + const vm = toViewModel(initialState()); + expect(vm.view).toBe("home"); + expect(vm.workflows.map((w) => w.id)).toEqual(["build", "review", "plan", "docs", "data", "coach"]); + expect(vm.workflows[0]).not.toHaveProperty("intent"); + }); + + it("carries the active workflow once launched", () => { + const vm = toViewModel(startLaunch(initialState(), "review")); + expect(vm.view).toBe("loop"); + expect(vm.activeWorkflow).toBe("review"); + }); + + it("exposes navigatorOpen, defaulting to false", () => { + expect(toViewModel(initialState()).navigatorOpen).toBe(false); + }); + }); +}); diff --git a/rpi-cockpit/tests/server.test.ts b/rpi-cockpit/tests/server.test.ts new file mode 100644 index 000000000..8fd5948fe --- /dev/null +++ b/rpi-cockpit/tests/server.test.ts @@ -0,0 +1,416 @@ +// rpi-cockpit/tests/server.test.ts +import { describe, it, expect, afterEach } from "vitest"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import { existsSync, readFileSync, rmSync, mkdirSync } from "node:fs"; +import WebSocket from "ws"; +import { Bridge } from "../src/bridge.js"; +import { startServer } from "../src/server.js"; + +let stop: (() => Promise) | null = null; +afterEach(async () => { if (stop) await stop(); stop = null; }); + +// Unique temp dir per test; Date.now()/pid avoids Math.random (banned in src, +// fine for test fixtures). +function tempDir(tag: string): string { + const dir = path.join(os.tmpdir(), `rpi-cockpit-test-${tag}-${process.pid}-${Date.now()}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +// Minimal HTTP GET helper: returns status, headers, and body for an assertion. +function get( + port: number, + pathname: string, + headers: http.OutgoingHttpHeaders = {}, +): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> { + return new Promise((resolve, reject) => { + const req = http.request( + { host: "127.0.0.1", port, path: pathname, method: "GET", headers }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (c) => chunks.push(c as Buffer)); + res.on("end", () => + resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks).toString("utf8") }), + ); + }, + ); + req.on("error", reject); + req.end(); + }); +} + +describe("server", () => { + it("pushes state on connect and round-trips a decision", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`); + const first = await new Promise((res) => ws.on("message", (d) => res(JSON.parse(String(d))))); + expect(first.type).toBe("state"); + + const choice = bridge.presentOptions("pick", [{ id: "a", title: "A" }]); + await new Promise((r) => setTimeout(r, 20)); + const id = bridge.state.decisions.at(-1)!.id; + ws.send(JSON.stringify({ type: "decide", id, choiceId: "a" })); + expect(await choice).toBe("a"); + ws.close(); + }); + + it("ignores a malformed inbound frame without crashing", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`); + await new Promise((r) => ws.on("open", r)); + ws.send("this is not json"); + const choice = bridge.presentOptions("pick", [{ id: "a", title: "A" }]); + await new Promise((r) => setTimeout(r, 20)); + ws.send(JSON.stringify({ type: "decide", id: bridge.state.decisions.at(-1)!.id, choiceId: "a" })); + expect(await choice).toBe("a"); + ws.close(); + }); + + it("includes a view model in the pushed frame", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`); + const first = await new Promise((res) => ws.on("message", (d) => res(JSON.parse(String(d))))); + expect(first.view.started).toBe(false); + expect(first.view.steerMenu.source).toBe("preset"); + ws.close(); + }); + + it("enqueues a directive from an inbound steer frame", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`); + await new Promise((r) => ws.on("open", r)); + ws.send(JSON.stringify({ type: "steer", directive: { kind: "note", text: "focus on errors" } })); + await new Promise((r) => setTimeout(r, 30)); + expect(bridge.state.directives).toHaveLength(1); + expect(bridge.state.directives[0]).toMatchObject({ kind: "note", text: "focus on errors" }); + ws.close(); + }); + + it("enqueues a directive from an inbound intervene frame", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`); + await new Promise((r) => ws.on("open", r)); + ws.send(JSON.stringify({ type: "intervene", action: "pause", agentId: "a1" })); + await new Promise((r) => setTimeout(r, 30)); + expect(bridge.state.directives).toHaveLength(1); + const d = bridge.state.directives[0]; + expect(d.kind).toBe("note"); + if (d.kind === "note") { + expect(d.text).toContain("pause"); + expect(d.text).toContain("a1"); + } + ws.close(); + }); + + it("ignores an intervene frame with an invalid action", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`); + await new Promise((r) => ws.on("open", r)); + ws.send(JSON.stringify({ type: "intervene", action: "delete", agentId: "a1" })); + await new Promise((r) => setTimeout(r, 30)); + expect(bridge.state.directives).toHaveLength(0); + ws.close(); + }); + + describe("auth", () => { + it("mints a per-session token and a keyed url in the return", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + expect(typeof srv.token).toBe("string"); + expect(srv.token).toMatch(/^[0-9a-f]{64}$/); // randomBytes(32) hex + expect(srv.url).toBe(`http://127.0.0.1:${srv.port}/?key=${srv.token}`); + }); + + it("HTTP GET / with no key and no cookie -> 403, serves no file", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const res = await get(srv.port, "/"); + expect(res.status).toBe(403); + expect(res.body).not.toContain("client.js"); // did not serve index.html + expect(res.body.toLowerCase()).toContain("key="); // tells user to open the keyed url + }); + + it("HTTP GET /?key= -> 200 index.html with a hardened Set-Cookie", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const res = await get(srv.port, `/?key=${srv.token}`); + expect(res.status).toBe(200); + expect(res.body.toLowerCase()).toContain(" -> 403", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const res = await get(srv.port, `/?key=deadbeef`); + expect(res.status).toBe(403); + }); + + it("HTTP GET /client.js with the cookie -> 200", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const res = await get(srv.port, "/client.js", { cookie: `rpi-cockpit-key-${srv.port}=${srv.token}` }); + expect(res.status).toBe(200); + expect(res.body).toContain("WebSocket"); + }); + + it("WS connect with no key -> rejected (never opens)", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}`); + const opened = await new Promise((resolve) => { + ws.on("open", () => resolve(true)); + ws.on("error", () => resolve(false)); + ws.on("close", () => resolve(false)); + }); + expect(opened).toBe(false); + try { ws.close(); } catch { /* already closed */ } + }); + + it("WS connect with ?key= -> opens and receives initial state", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`); + const first = await new Promise((res, rej) => { + ws.on("message", (d) => res(JSON.parse(String(d)))); + ws.on("error", rej); + }); + expect(first.type).toBe("state"); + ws.close(); + }); + + it("WS connect with a present but WRONG Origin -> rejected", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`, { + headers: { origin: "http://evil.example" }, + }); + const opened = await new Promise((resolve) => { + ws.on("open", () => resolve(true)); + ws.on("error", () => resolve(false)); + ws.on("close", () => resolve(false)); + }); + expect(opened).toBe(false); + try { ws.close(); } catch { /* already closed */ } + }); + + it("WS connect with a matching Origin + key -> accepted", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`, { + headers: { origin: `http://127.0.0.1:${srv.port}` }, + }); + const first = await new Promise((res, rej) => { + ws.on("message", (d) => res(JSON.parse(String(d)))); + ws.on("error", rej); + }); + expect(first.type).toBe("state"); + ws.close(); + }); + + it("a decide frame over an authorized WS still resolves the decision", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`); + await new Promise((r) => ws.on("open", r)); + const choice = bridge.presentOptions("pick", [{ id: "a", title: "A" }]); + await new Promise((r) => setTimeout(r, 20)); + ws.send(JSON.stringify({ type: "decide", id: bridge.state.decisions.at(-1)!.id, choiceId: "a" })); + expect(await choice).toBe("a"); + ws.close(); + }); + }); + + it("a launch frame enqueues a directive and flips the view to loop", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`); + await new Promise((res) => ws.on("message", (d) => res(JSON.parse(String(d))))); + const settled = new Promise((res) => bridge.once("state", () => res())); + ws.send(JSON.stringify({ type: "launch", workflowId: "build" })); + await settled; + expect(bridge.state.view).toBe("loop"); + expect(bridge.state.activeWorkflow).toBe("build"); + expect(bridge.state.directives).toHaveLength(1); + expect(bridge.state.directives[0]).toMatchObject({ kind: "approach", value: "build" }); + ws.close(); + }); + + it("an answer frame resolves a pending question", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const p = bridge.askQuestion("Q?", 0); + const qid = bridge.state.decisions.at(-1)!.id; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`); + await new Promise((res) => ws.on("message", (d) => res(JSON.parse(String(d))))); + const settled = new Promise((res) => bridge.once("state", () => res())); + ws.send(JSON.stringify({ type: "answer", id: qid, text: "answered" })); + await settled; + expect(await p).toBe("answered"); + ws.close(); + }); + + it("a navigator frame opens and closes the navigator", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`); + await new Promise((res) => ws.on("message", (d) => res(JSON.parse(String(d))))); + const opened = new Promise((res) => bridge.once("state", () => res())); + ws.send(JSON.stringify({ type: "navigator", open: true })); + await opened; + expect(bridge.state.navigatorOpen).toBe(true); + const closed = new Promise((res) => bridge.once("state", () => res())); + ws.send(JSON.stringify({ type: "navigator", open: false })); + await closed; + expect(bridge.state.navigatorOpen).toBe(false); + ws.close(); + }); + + it("a navigate frame sets the view", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + bridge.navigate("loop"); + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`); + await new Promise((res) => ws.on("message", (d) => res(JSON.parse(String(d))))); + const settled = new Promise((res) => bridge.once("state", () => res())); + ws.send(JSON.stringify({ type: "navigate", screen: "home" })); + await settled; + expect(bridge.state.view).toBe("home"); + ws.close(); + }); + + describe("embed mode (trustLoopback)", () => { + it("HTTP GET / with no key and no cookie -> 200 index.html", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0, { trustLoopback: true }); + stop = srv.close; + const res = await get(srv.port, "/"); + expect(res.status).toBe(200); + expect(res.body.toLowerCase()).toContain(" { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0, { trustLoopback: true }); + stop = srv.close; + const res = await get(srv.port, `/?key=${srv.token}`); + expect(res.status).toBe(200); + expect(res.body.toLowerCase()).toContain(" { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0); + stop = srv.close; + const res = await get(srv.port, "/"); + expect(res.status).toBe(403); + }); + + it("WS connect with NO key -> opens and receives initial state", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0, { trustLoopback: true }); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}`); + const first = await new Promise((res, rej) => { + ws.on("message", (d) => res(JSON.parse(String(d)))); + ws.on("error", rej); + }); + expect(first.type).toBe("state"); + ws.close(); + }); + + it("WS connect with a WRONG Origin is still rejected in embed mode", async () => { + const bridge = new Bridge(); + const srv = await startServer(bridge, 0, { trustLoopback: true }); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}`, { + headers: { origin: "http://evil.example" }, + }); + const opened = await new Promise((resolve) => { + ws.on("open", () => resolve(true)); + ws.on("error", () => resolve(false)); + ws.on("close", () => resolve(false)); + }); + expect(opened).toBe(false); + try { ws.close(); } catch { /* already closed */ } + }); + }); + + describe("writeStateSnapshot (producer)", () => { + it("writes state.json atomically that round-trips the bridge state", async () => { + const dir = tempDir("snapshot"); + const bridge = new Bridge(); + const srv = await startServer(bridge, 0, { stateDir: dir, writeStateSnapshot: true }); + stop = async () => { await srv.close(); rmSync(dir, { recursive: true, force: true }); }; + bridge.emitBeat({ type: "session.begin", task: "snap task", host: "claude-code" }); + // Synchronous write happens inside the emit, so the file is already there. + const statePath = path.join(dir, "state.json"); + expect(existsSync(statePath)).toBe(true); + const parsed = JSON.parse(readFileSync(statePath, "utf8")); + expect(parsed.task).toBe("snap task"); + expect(parsed.domain).toBe(bridge.state.domain); + expect(parsed.task).toBe(bridge.state.task); + // No half-written temp left behind after a successful rename. + expect(existsSync(path.join(dir, "state.json.tmp"))).toBe(false); + }); + + it("is OFF by default: no state.json without the flag", async () => { + const dir = tempDir("nosnap"); + const bridge = new Bridge(); + const srv = await startServer(bridge, 0, { stateDir: dir }); + stop = async () => { await srv.close(); rmSync(dir, { recursive: true, force: true }); }; + bridge.emitBeat({ type: "session.begin", task: "x", host: "h" }); + expect(existsSync(path.join(dir, "state.json"))).toBe(false); + }); + }); + + describe("onInbound (consumer routing)", () => { + it("hands inbound frames to the callback and does NOT mutate the bridge", async () => { + const bridge = new Bridge(); + const received: unknown[] = []; + const srv = await startServer(bridge, 0, { onInbound: (f) => received.push(f) }); + stop = srv.close; + const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/?key=${srv.token}`); + await new Promise((r) => ws.on("open", r)); + ws.send(JSON.stringify({ type: "steer", directive: { kind: "note", text: "to inbox" } })); + await new Promise((r) => setTimeout(r, 40)); + expect(received).toHaveLength(1); + expect(received[0]).toEqual({ type: "steer", directive: { kind: "note", text: "to inbox" } }); + // The bridge was NOT driven: the directive went to the callback only. + expect(bridge.state.directives).toHaveLength(0); + ws.close(); + }); + }); +}); diff --git a/rpi-cockpit/tests/smoke.test.ts b/rpi-cockpit/tests/smoke.test.ts new file mode 100644 index 000000000..63266fcbb --- /dev/null +++ b/rpi-cockpit/tests/smoke.test.ts @@ -0,0 +1,128 @@ +// rpi-cockpit/tests/smoke.test.ts +// Client smoke test: load the real index.html + client.js into happy-dom, feed a +// view model the way the server does (a {type:"state", view} WS frame), and assert +// the painter's behaviour — with special focus on the SECURITY property of the +// agent-authored screen pane: its HTML must render inside a sandboxed