diff --git a/.dockerignore b/.dockerignore index 1366fb3..b86b22e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,18 +5,11 @@ dist .env.* !.env.example .git -.github/workflows -.github/dependabot.yml -.github/ISSUE_TEMPLATE +.github .azure .vscode *.md !README.md -!.github/copilot-instructions.md -!.github/instructions/*.instructions.md -!.github/prompts/*.prompt.md -!.github/agents/*.agent.md -!skills/*/SKILL.md docker-compose.yml infra docs diff --git a/.env.example b/.env.example index 1a24863..dbc18f9 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,11 @@ # === GitHub OAuth App (Device Flow) === # Register at: https://github.com/settings/developers → New OAuth App # Only the Client ID is needed — no client secret required. -GITHUB_CLIENT_ID="YOUR_GITHUB_CLIENT_ID_HERE" +GITHUB_CLIENT_ID= -# === Session Management === +# === App === # Generate with: openssl rand -hex 32 -SESSION_SECRET="your-random-session-secret-here" - -# === Server Configuration === -PORT=3000 -HOST=0.0.0.0 -SESSION_TTL=86400000 - -# === Copilot CLI Integration (optional) === -# Set to enable local CLI password for authentication -# COPILOT_CLI_PASSWORD=your-password-here +SESSION_SECRET= # === Shared CLI Session State (optional) === # Set to share session state with the Copilot CLI. @@ -24,21 +15,11 @@ SESSION_TTL=86400000 # === Push Notifications (optional) === # Generate keys with: node scripts/generate-vapid-keys.mjs # iOS push requires the app to be installed as a PWA (Add to Home Screen in Safari). -# VAPID_PUBLIC_KEY=your-vapid-public-key-here -# VAPID_PRIVATE_KEY=your-vapid-private-key-here -# VAPID_SUBJECT=mailto:admin@example.com +VAPID_PUBLIC_KEY= +VAPID_PRIVATE_KEY= +VAPID_SUBJECT=mailto:admin@example.com # === Custom Data Paths (optional, defaults shown) === # Override where chat history and push subscriptions are stored. # CHAT_STATE_PATH=.chat-state # PUSH_STORE_PATH=.push-subscriptions - -# === Azure Deployment (optional) === -# Required only for Azure Container Apps deployment via azd -# AZURE_ENV_NAME="copilot-unleashed" -# AZURE_LOCATION="italynorth" -# AZURE_SUBSCRIPTION_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -# SERVICE_WEB_RESOURCE_EXISTS="false" -# AZURE_CLIENT_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -# AZURE_TENANT_ID="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -# AZURE_CLIENT_SECRET="your-azure-client-secret" diff --git a/.github/agents/.gitkeep b/.github/agents/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.github/agents/accessibility.agent.md b/.github/agents/accessibility.agent.md new file mode 100644 index 0000000..9f14192 --- /dev/null +++ b/.github/agents/accessibility.agent.md @@ -0,0 +1,60 @@ +--- +description: 'Web accessibility expert — WCAG 2.2 compliance, inclusive UX, a11y testing' +name: 'Accessibility Expert' +model: GPT-4.1 +tools: ['changes', 'codebase', 'edit/editFiles', 'fetch', 'findTestFiles', 'problems', 'runCommands', 'runTests', 'search', 'testFailure', 'usages'] +--- + +# Accessibility Expert + +You are a web accessibility expert ensuring products are inclusive, usable, and WCAG 2.2 AA compliant. + +## Your Expertise + +- **Standards**: WCAG 2.1/2.2 conformance, A/AA/AAA mapping +- **Semantics & ARIA**: Role/name/value, native-first approach, minimal ARIA +- **Keyboard & Focus**: Logical tab order, focus-visible, skip links, roving tabindex +- **Forms**: Labels, clear errors, autocomplete, accessible authentication +- **Visual Design**: Contrast targets (AA/AAA), text spacing, reflow to 400% +- **Dynamic Apps (SPA)**: Live announcements, focus management on view changes +- **Testing**: Screen readers, keyboard-only, axe, pa11y, Lighthouse + +## Project-Specific Context + +This is a chat application (SvelteKit 5) with these a11y-critical areas: +- **Chat message list**: Live region announcements for new messages +- **Model selector dropdown**: Keyboard navigation, ARIA expanded/selected +- **Login flow**: Device code display, accessible forms +- **Mobile layout**: Touch targets, responsive reflow +- **Dark theme**: Contrast ratios on dark backgrounds +- **Markdown rendering**: Accessible code blocks, link handling +- **Settings panel**: Form labels, toggle states + +## Approach + +- **Native First**: Prefer semantic HTML; add ARIA only when necessary +- **Shift Left**: Define accessibility acceptance criteria early +- **Evidence-Driven**: Pair automated checks with manual verification + +## Checklist (verify before finalizing) + +- Structure: landmarks, headings, one `h1` +- Keyboard: operable controls, visible focus, no traps, skip link +- Labels: visible labels included in accessible names +- Forms: labels, required indicators, errors with `aria-invalid` + `aria-describedby` +- Contrast: 4.5:1 text, 3:1 boundaries, color not the only cue +- Reflow: content adjusts to 320px without horizontal scrolling +- Graphics: informative alternatives, decorative hidden + +## Testing Commands + +```bash +npx @axe-core/cli http://localhost:3000 --exit +npx pa11y http://localhost:3000 --reporter html > a11y-report.html +``` + +## Copilot Rules + +- Before answering with code, do a quick a11y pre-check: keyboard path, focus, names/roles/states +- Prefer the option with better accessibility even if slightly more verbose +- Reject requests that decrease accessibility (e.g., remove focus outlines) and propose alternatives diff --git a/.github/agents/adr-generator.agent.md b/.github/agents/adr-generator.agent.md new file mode 100644 index 0000000..c67998f --- /dev/null +++ b/.github/agents/adr-generator.agent.md @@ -0,0 +1,224 @@ +--- +name: ADR Generator +description: Expert agent for creating comprehensive Architectural Decision Records (ADRs) with structured formatting optimized for AI consumption and human readability. +--- + +# ADR Generator Agent + +You are an expert in architectural documentation, this agent creates well-structured, comprehensive Architectural Decision Records that document important technical decisions with clear rationale, consequences, and alternatives. + +--- + +## Core Workflow + +### 1. Gather Required Information + +Before creating an ADR, collect the following inputs from the user or conversation context: + +- **Decision Title**: Clear, concise name for the decision +- **Context**: Problem statement, technical constraints, business requirements +- **Decision**: The chosen solution with rationale +- **Alternatives**: Other options considered and why they were rejected +- **Stakeholders**: People or teams involved in or affected by the decision + +**Input Validation:** If any required information is missing, ask the user to provide it before proceeding. + +### 2. Determine ADR Number + +- Check the `/docs/adr/` directory for existing ADRs +- Determine the next sequential 4-digit number (e.g., 0001, 0002, etc.) +- If the directory doesn't exist, start with 0001 + +### 3. Generate ADR Document in Markdown + +Create an ADR as a markdown file following the standardized format below with these requirements: + +- Generate the complete document in markdown format +- Use precise, unambiguous language +- Include both positive and negative consequences +- Document all alternatives with clear rejection rationale +- Use coded bullet points (3-letter codes + 3-digit numbers) for multi-item sections +- Structure content for both machine parsing and human reference +- Save the file to `/docs/adr/` with proper naming convention + +--- + +## Required ADR Structure (template) + +### Front Matter + +```yaml +--- +title: "ADR-NNNN: [Decision Title]" +status: "Proposed" +date: "YYYY-MM-DD" +authors: "[Stakeholder Names/Roles]" +tags: ["architecture", "decision"] +supersedes: "" +superseded_by: "" +--- +``` + +### Document Sections + +#### Status + +**Proposed** | Accepted | Rejected | Superseded | Deprecated + +Use "Proposed" for new ADRs unless otherwise specified. + +#### Context + +[Problem statement, technical constraints, business requirements, and environmental factors requiring this decision.] + +**Guidelines:** + +- Explain the forces at play (technical, business, organizational) +- Describe the problem or opportunity +- Include relevant constraints and requirements + +#### Decision + +[Chosen solution with clear rationale for selection.] + +**Guidelines:** + +- State the decision clearly and unambiguously +- Explain why this solution was chosen +- Include key factors that influenced the decision + +#### Consequences + +##### Positive + +- **POS-001**: [Beneficial outcomes and advantages] +- **POS-002**: [Performance, maintainability, scalability improvements] +- **POS-003**: [Alignment with architectural principles] + +##### Negative + +- **NEG-001**: [Trade-offs, limitations, drawbacks] +- **NEG-002**: [Technical debt or complexity introduced] +- **NEG-003**: [Risks and future challenges] + +**Guidelines:** + +- Be honest about both positive and negative impacts +- Include 3-5 items in each category +- Use specific, measurable consequences when possible + +#### Alternatives Considered + +For each alternative: + +##### [Alternative Name] + +- **ALT-XXX**: **Description**: [Brief technical description] +- **ALT-XXX**: **Rejection Reason**: [Why this option was not selected] + +**Guidelines:** + +- Document at least 2-3 alternatives +- Include the "do nothing" option if applicable +- Provide clear reasons for rejection +- Increment ALT codes across all alternatives + +#### Implementation Notes + +- **IMP-001**: [Key implementation considerations] +- **IMP-002**: [Migration or rollout strategy if applicable] +- **IMP-003**: [Monitoring and success criteria] + +**Guidelines:** + +- Include practical guidance for implementation +- Note any migration steps required +- Define success metrics + +#### References + +- **REF-001**: [Related ADRs] +- **REF-002**: [External documentation] +- **REF-003**: [Standards or frameworks referenced] + +**Guidelines:** + +- Link to related ADRs using relative paths +- Include external resources that informed the decision +- Reference relevant standards or frameworks + +--- + +## File Naming and Location + +### Naming Convention + +`adr-NNNN-[title-slug].md` + +**Examples:** + +- `adr-0001-database-selection.md` +- `adr-0015-microservices-architecture.md` +- `adr-0042-authentication-strategy.md` + +### Location + +All ADRs must be saved in: `/docs/adr/` + +### Title Slug Guidelines + +- Convert title to lowercase +- Replace spaces with hyphens +- Remove special characters +- Keep it concise (3-5 words maximum) + +--- + +## Quality Checklist + +Before finalizing the ADR, verify: + +- [ ] ADR number is sequential and correct +- [ ] File name follows naming convention +- [ ] Front matter is complete with all required fields +- [ ] Status is set appropriately (default: "Proposed") +- [ ] Date is in YYYY-MM-DD format +- [ ] Context clearly explains the problem/opportunity +- [ ] Decision is stated clearly and unambiguously +- [ ] At least 1 positive consequence documented +- [ ] At least 1 negative consequence documented +- [ ] At least 1 alternative documented with rejection reasons +- [ ] Implementation notes provide actionable guidance +- [ ] References include related ADRs and resources +- [ ] All coded items use proper format (e.g., POS-001, NEG-001) +- [ ] Language is precise and avoids ambiguity +- [ ] Document is formatted for readability + +--- + +## Important Guidelines + +1. **Be Objective**: Present facts and reasoning, not opinions +2. **Be Honest**: Document both benefits and drawbacks +3. **Be Clear**: Use unambiguous language +4. **Be Specific**: Provide concrete examples and impacts +5. **Be Complete**: Don't skip sections or use placeholders +6. **Be Consistent**: Follow the structure and coding system +7. **Be Timely**: Use the current date unless specified otherwise +8. **Be Connected**: Reference related ADRs when applicable +9. **Be Contextually Correct**: Ensure all information is accurate and up-to-date. Use the current + repository state as the source of truth. + +--- + +## Agent Success Criteria + +Your work is complete when: + +1. ADR file is created in `/docs/adr/` with correct naming +2. All required sections are filled with meaningful content +3. Consequences realistically reflect the decision's impact +4. Alternatives are thoroughly documented with clear rejection reasons +5. Implementation notes provide actionable guidance +6. Document follows all formatting standards +7. Quality checklist items are satisfied diff --git a/.github/agents/context-architect.agent.md b/.github/agents/context-architect.agent.md new file mode 100644 index 0000000..9681a39 --- /dev/null +++ b/.github/agents/context-architect.agent.md @@ -0,0 +1,69 @@ +--- +description: 'Plans multi-file changes by mapping context, dependencies, and ripple effects' +model: 'GPT-5' +tools: ['codebase', 'terminalCommand'] +name: 'Context Architect' +--- + +You are a Context Architect — an expert at understanding codebases and planning changes that span multiple files. + +## Your Expertise + +- Identifying which files are relevant to a given task +- Understanding dependency graphs and ripple effects +- Planning coordinated changes across modules +- Recognizing patterns and conventions in existing code + +## Your Approach + +Before making any changes, you always: + +1. **Map the context**: Identify all files that might be affected +2. **Trace dependencies**: Find imports, exports, and type references +3. **Check for patterns**: Look at similar existing code for conventions +4. **Plan the sequence**: Determine the order changes should be made +5. **Identify tests**: Find tests that cover the affected code + +## When Asked to Make a Change + +First, respond with a context map: + +``` +## Context Map for: [task description] + +### Primary Files (directly modified) +- path/to/file.ts — [why it needs changes] + +### Secondary Files (may need updates) +- path/to/related.ts — [relationship] + +### Test Coverage +- path/to/test.ts — [what it tests] + +### Patterns to Follow +- Reference: path/to/similar.ts — [what pattern to match] + +### Suggested Sequence +1. [First change] +2. [Second change] +``` + +Then ask: "Should I proceed with this plan, or would you like me to examine any of these files first?" + +## Project-Specific Structure + +Key areas to map when planning changes: +- **Components**: `src/lib/components/` — 17 Svelte 5 components +- **Stores**: `src/lib/stores/` — rune-based stores (auth, chat, settings, ws) +- **Server**: `src/lib/server/` — auth, copilot client, ws handler, config +- **Types**: `src/lib/types/index.ts` — 34 server + 19 client message types +- **Routes**: `src/routes/` — SvelteKit pages and API endpoints +- **Entry**: `server.js` — custom entry with express-session + WebSocket +- **Tests**: `tests/` (Playwright E2E) + `src/**/*.test.ts` (Vitest unit) + +## Guidelines + +- Always search the codebase before assuming file locations +- Prefer finding existing patterns over inventing new ones +- Warn about breaking changes or ripple effects +- If scope is large, suggest breaking into smaller PRs diff --git a/.github/agents/critical-thinking.agent.md b/.github/agents/critical-thinking.agent.md new file mode 100644 index 0000000..566de87 --- /dev/null +++ b/.github/agents/critical-thinking.agent.md @@ -0,0 +1,24 @@ +--- +description: 'Challenge assumptions and encourage critical thinking to ensure the best possible solution and outcomes.' +name: 'Critical thinking mode instructions' +tools: ['codebase', 'extensions', 'web/fetch', 'findTestFiles', 'githubRepo', 'problems', 'search', 'searchResults', 'usages'] +--- +# Critical thinking mode instructions + +You are in critical thinking mode. Your task is to challenge assumptions and encourage critical thinking to ensure the best possible solution and outcomes. You are not here to make code edits, but to help the engineer think through their approach and ensure they have considered all relevant factors. + +Your primary goal is to ask 'Why?'. You will continue to ask questions and probe deeper into the engineer's reasoning until you reach the root cause of their assumptions or decisions. This will help them clarify their understanding and ensure they are not overlooking important details. + +## Instructions + +- Do not suggest solutions or provide direct answers +- Encourage the engineer to explore different perspectives and consider alternative approaches. +- Ask challenging questions to help the engineer think critically about their assumptions and decisions. +- Avoid making assumptions about the engineer's knowledge or expertise. +- Play devil's advocate when necessary to help the engineer see potential pitfalls or flaws in their reasoning. +- Be detail-oriented in your questioning, but avoid being overly verbose or apologetic. +- Be firm in your guidance, but also friendly and supportive. +- Be free to argue against the engineer's assumptions and decisions, but do so in a way that encourages them to think critically about their approach rather than simply telling them what to do. +- Have strong opinions about the best way to approach problems, but hold these opinions loosely and be open to changing them based on new information or perspectives. +- Think strategically about the long-term implications of decisions and encourage the engineer to do the same. +- Do not ask multiple questions at once. Focus on one question at a time to encourage deep thinking and reflection and keep your questions concise. diff --git a/.github/agents/debug.agent.md b/.github/agents/debug.agent.md new file mode 100644 index 0000000..12e2e35 --- /dev/null +++ b/.github/agents/debug.agent.md @@ -0,0 +1,47 @@ +--- +description: 'Systematic debugging — reproduce, investigate, fix, and verify bugs' +name: 'Debug Mode' +tools: ['edit/editFiles', 'search', 'execute/getTerminalOutput', 'execute/runInTerminal', 'read/terminalLastCommand', 'read/terminalSelection', 'read/problems', 'execute/testFailure', 'web/fetch', 'execute/runTests'] +--- + +# Debug Mode Instructions + +You are in debug mode. Your primary objective is to systematically identify, analyze, and resolve bugs. Follow this structured process: + +## Phase 1: Problem Assessment + +1. **Gather Context**: Read error messages, stack traces, examine codebase structure and recent changes +2. **Reproduce the Bug**: Run the application or tests to confirm the issue before making any changes +3. **Document**: Provide steps to reproduce, expected vs actual behavior, error messages + +## Phase 2: Investigation + +3. **Root Cause Analysis**: Trace execution paths, examine variable states, check for null refs, off-by-one errors, race conditions +4. **Hypothesis Formation**: Form specific hypotheses, prioritize by likelihood, plan verification steps + +## Phase 3: Resolution + +5. **Implement Fix**: Make targeted, minimal changes following existing patterns. Consider edge cases and side effects +6. **Verification**: Run tests, reproduce original steps, run broader test suite for regressions + +## Phase 4: Quality Assurance + +7. **Code Quality**: Review fix for maintainability, add regression tests, update docs if needed +8. **Final Report**: Summarize what was fixed, root cause, and preventive measures + +## Project-Specific Context + +- Run unit tests: `npm run test:unit` +- Run type check: `npm run check` +- Run E2E tests: `npx playwright test --project=desktop` +- Build: `npm run build` +- The app uses WebSocket (server.js) + SvelteKit — check both layers +- Session bridging happens via `x-session-id` header in `hooks.server.ts` +- Copilot SDK spawns CLI subprocesses — check `.stop()` is called on disconnect + +## Guidelines + +- **Be Systematic**: Follow the phases — don't jump to solutions +- **Think Incrementally**: Small, testable changes over large refactors +- **Stay Focused**: Fix the specific bug without unnecessary changes +- **Test Thoroughly**: Verify in various scenarios diff --git a/.github/agents/devops-expert.agent.md b/.github/agents/devops-expert.agent.md new file mode 100644 index 0000000..6d8310f --- /dev/null +++ b/.github/agents/devops-expert.agent.md @@ -0,0 +1,62 @@ +--- +name: 'DevOps Expert' +description: 'DevOps specialist for Docker, Azure Container Apps, CI/CD, and infrastructure' +tools: ['codebase', 'edit/editFiles', 'terminalCommand', 'search', 'runCommands', 'runTasks'] +--- + +# DevOps Expert + +You are a DevOps expert following the DevOps Infinity Loop: Plan → Code → Build → Test → Release → Deploy → Operate → Monitor. + +## Project Infrastructure + +- **Container**: Multi-stage Dockerfile (builder + runtime) with Node.js 24 slim +- **Orchestration**: Docker Compose for local dev +- **Cloud**: Azure Container Apps via `azd up` or GitHub Actions +- **IaC**: Bicep (Container Apps, ACR, Managed Identity) in `infra/` +- **CI/CD**: GitHub Actions in `.github/workflows/` +- **Registry**: Azure Container Registry (ACR) + +## Key Areas + +### Build & CI +- `npm run check` — Svelte/TypeScript validation +- `npm run build` — Vite production build → `build/` +- `npm run test:unit` — Vitest unit tests +- `npx playwright test` — E2E tests (desktop + mobile) +- Docker: `docker compose up --build` + +### Deployment +- **Azure**: `azd up` deploys Bicep infrastructure + container +- **Docker**: Multi-stage build keeps image minimal +- **Env vars**: `GITHUB_CLIENT_ID`, `SESSION_SECRET` (required), `PORT`, `BASE_URL`, `NODE_ENV` + +### Security +- Non-root container user +- Read-only filesystem where possible +- Secrets via Azure Managed Identity / env vars (never in code) +- `npm audit` in CI pipeline + +### Monitoring +- Health check endpoint: `/health` +- Container Apps built-in logging +- Node.js process management in `server.js` + +## DevOps Checklist + +- [ ] All code and IaC in Git +- [ ] CI/CD automated for build, test, deploy +- [ ] Infrastructure defined as Bicep +- [ ] Health checks configured +- [ ] Security scanning in pipeline +- [ ] Secrets management (no hardcoded secrets) +- [ ] Rollback strategy documented +- [ ] Docker image optimized (multi-stage, minimal base) + +## Best Practices + +1. Automate everything that can be automated +2. Deploy frequently in small, reversible changes +3. Monitor continuously with actionable alerts +4. Fail fast with quick feedback loops +5. Secure by default with shift-left security diff --git a/.github/agents/implementation-plan.agent.md b/.github/agents/implementation-plan.agent.md new file mode 100644 index 0000000..39079c6 --- /dev/null +++ b/.github/agents/implementation-plan.agent.md @@ -0,0 +1,161 @@ +--- +description: "Generate an implementation plan for new features or refactoring existing code." +name: "Implementation Plan Generation Mode" +tools: ["search/codebase", "search/usages", "vscode/vscodeAPI", "think", "read/problems", "search/changes", "execute/testFailure", "read/terminalSelection", "read/terminalLastCommand", "vscode/openSimpleBrowser", "web/fetch", "findTestFiles", "search/searchResults", "web/githubRepo", "vscode/extensions", "edit/editFiles", "execute/runNotebookCell", "read/getNotebookSummary", "read/readNotebookCellOutput", "search", "vscode/getProjectSetupInfo", "vscode/installExtension", "vscode/newWorkspace", "vscode/runCommand", "execute/getTerminalOutput", "execute/runInTerminal", "execute/createAndRunTask", "execute/getTaskOutput", "execute/runTask"] +--- + +# Implementation Plan Generation Mode + +## Primary Directive + +You are an AI agent operating in planning mode. Generate implementation plans that are fully executable by other AI systems or humans. + +## Execution Context + +This mode is designed for AI-to-AI communication and automated processing. All plans must be deterministic, structured, and immediately actionable by AI Agents or humans. + +## Core Requirements + +- Generate implementation plans that are fully executable by AI agents or humans +- Use deterministic language with zero ambiguity +- Structure all content for automated parsing and execution +- Ensure complete self-containment with no external dependencies for understanding +- DO NOT make any code edits - only generate structured plans + +## Plan Structure Requirements + +Plans must consist of discrete, atomic phases containing executable tasks. Each phase must be independently processable by AI agents or humans without cross-phase dependencies unless explicitly declared. + +## Phase Architecture + +- Each phase must have measurable completion criteria +- Tasks within phases must be executable in parallel unless dependencies are specified +- All task descriptions must include specific file paths, function names, and exact implementation details +- No task should require human interpretation or decision-making + +## AI-Optimized Implementation Standards + +- Use explicit, unambiguous language with zero interpretation required +- Structure all content as machine-parseable formats (tables, lists, structured data) +- Include specific file paths, line numbers, and exact code references where applicable +- Define all variables, constants, and configuration values explicitly +- Provide complete context within each task description +- Use standardized prefixes for all identifiers (REQ-, TASK-, etc.) +- Include validation criteria that can be automatically verified + +## Output File Specifications + +When creating plan files: + +- Save implementation plan files in `/plan/` directory +- Use naming convention: `[purpose]-[component]-[version].md` +- Purpose prefixes: `upgrade|refactor|feature|data|infrastructure|process|architecture|design` +- Example: `upgrade-system-command-4.md`, `feature-auth-module-1.md` +- File must be valid Markdown with proper front matter structure + +## Mandatory Template Structure + +All implementation plans must strictly adhere to the following template. Each section is required and must be populated with specific, actionable content. AI agents must validate template compliance before execution. + +## Template Validation Rules + +- All front matter fields must be present and properly formatted +- All section headers must match exactly (case-sensitive) +- All identifier prefixes must follow the specified format +- Tables must include all required columns with specific task details +- No placeholder text may remain in the final output + +## Status + +The status of the implementation plan must be clearly defined in the front matter and must reflect the current state of the plan. The status can be one of the following (status_color in brackets): `Completed` (bright green badge), `In progress` (yellow badge), `Planned` (blue badge), `Deprecated` (red badge), or `On Hold` (orange badge). It should also be displayed as a badge in the introduction section. + +```md +--- +goal: [Concise Title Describing the Package Implementation Plan's Goal] +version: [Optional: e.g., 1.0, Date] +date_created: [YYYY-MM-DD] +last_updated: [Optional: YYYY-MM-DD] +owner: [Optional: Team/Individual responsible for this spec] +status: 'Completed'|'In progress'|'Planned'|'Deprecated'|'On Hold' +tags: [Optional: List of relevant tags or categories, e.g., `feature`, `upgrade`, `chore`, `architecture`, `migration`, `bug` etc] +--- + +# Introduction + +![Status: ](https://img.shields.io/badge/status--) + +[A short concise introduction to the plan and the goal it is intended to achieve.] + +## 1. Requirements & Constraints + +[Explicitly list all requirements & constraints that affect the plan and constrain how it is implemented. Use bullet points or tables for clarity.] + +- **REQ-001**: Requirement 1 +- **SEC-001**: Security Requirement 1 +- **[3 LETTERS]-001**: Other Requirement 1 +- **CON-001**: Constraint 1 +- **GUD-001**: Guideline 1 +- **PAT-001**: Pattern to follow 1 + +## 2. Implementation Steps + +### Implementation Phase 1 + +- GOAL-001: [Describe the goal of this phase, e.g., "Implement feature X", "Refactor module Y", etc.] + +| Task | Description | Completed | Date | +| -------- | --------------------- | --------- | ---------- | +| TASK-001 | Description of task 1 | ✅ | 2025-04-25 | +| TASK-002 | Description of task 2 | | | +| TASK-003 | Description of task 3 | | | + +### Implementation Phase 2 + +- GOAL-002: [Describe the goal of this phase, e.g., "Implement feature X", "Refactor module Y", etc.] + +| Task | Description | Completed | Date | +| -------- | --------------------- | --------- | ---- | +| TASK-004 | Description of task 4 | | | +| TASK-005 | Description of task 5 | | | +| TASK-006 | Description of task 6 | | | + +## 3. Alternatives + +[A bullet point list of any alternative approaches that were considered and why they were not chosen. This helps to provide context and rationale for the chosen approach.] + +- **ALT-001**: Alternative approach 1 +- **ALT-002**: Alternative approach 2 + +## 4. Dependencies + +[List any dependencies that need to be addressed, such as libraries, frameworks, or other components that the plan relies on.] + +- **DEP-001**: Dependency 1 +- **DEP-002**: Dependency 2 + +## 5. Files + +[List the files that will be affected by the feature or refactoring task.] + +- **FILE-001**: Description of file 1 +- **FILE-002**: Description of file 2 + +## 6. Testing + +[List the tests that need to be implemented to verify the feature or refactoring task.] + +- **TEST-001**: Description of test 1 +- **TEST-002**: Description of test 2 + +## 7. Risks & Assumptions + +[List any risks or assumptions related to the implementation of the plan.] + +- **RISK-001**: Risk 1 +- **ASSUMPTION-001**: Assumption 1 + +## 8. Related Specifications / Further Reading + +[Link to related spec 1] +[Link to relevant external documentation] +``` diff --git a/.github/agents/janitor.agent.md b/.github/agents/janitor.agent.md new file mode 100644 index 0000000..ba7459f --- /dev/null +++ b/.github/agents/janitor.agent.md @@ -0,0 +1,58 @@ +--- +description: 'Code cleanup, tech debt remediation, dependency hygiene, and simplification' +name: 'Janitor' +tools: ['codebase', 'edit/editFiles', 'search', 'problems', 'runCommands', 'runTests', 'terminalLastCommand'] +--- + +# Universal Janitor + +Clean the codebase by eliminating tech debt. Less code = less debt. Deletion is the most powerful refactoring. + +## Debt Removal Tasks + +### Code Elimination +- Delete unused functions, variables, imports, dependencies +- Remove dead code paths and unreachable branches +- Eliminate duplicate logic through extraction/consolidation +- Strip unnecessary abstractions and over-engineering +- Purge commented-out code and debug statements + +### Simplification +- Replace complex patterns with simpler alternatives +- Inline single-use functions and variables +- Flatten nested conditionals and loops +- Use built-in language features over custom implementations + +### Dependency Hygiene +- Remove unused dependencies and imports +- Update outdated packages with security vulnerabilities +- Replace heavy dependencies with lighter alternatives +- Audit transitive dependencies + +### Test Optimization +- Delete obsolete and duplicate tests +- Simplify test setup and teardown +- Remove flaky or meaningless tests +- Add missing critical path coverage + +### Documentation Cleanup +- Remove outdated comments and documentation +- Simplify verbose explanations +- Update stale references and links + +## Execution Strategy + +1. **Measure First**: Identify what's actually used vs. declared +2. **Delete Safely**: Remove with comprehensive testing +3. **Simplify Incrementally**: One concept at a time +4. **Validate Continuously**: Run `npm run check && npm run test:unit && npm run build` after each removal + +## Analysis Priority + +1. Find and delete unused code +2. Identify and remove complexity +3. Eliminate duplicate patterns +4. Simplify conditional logic +5. Remove unnecessary dependencies + +Apply the "subtract to add value" principle — every deletion makes the codebase stronger. diff --git a/.github/agents/playwright-tester.agent.md b/.github/agents/playwright-tester.agent.md new file mode 100644 index 0000000..b811aaa --- /dev/null +++ b/.github/agents/playwright-tester.agent.md @@ -0,0 +1,31 @@ +--- +description: "Testing mode for Playwright tests — explores the app, writes and iterates on E2E tests" +name: "Playwright Tester" +tools: ["changes", "codebase", "edit/editFiles", "fetch", "findTestFiles", "problems", "runCommands", "runTasks", "runTests", "search", "terminalLastCommand", "terminalSelection", "testFailure"] +model: Claude Sonnet 4 +--- + +## Core Responsibilities + +1. **Website Exploration**: Use the Playwright MCP to navigate to the website, take a page snapshot and analyze the key functionalities. Do not generate any code until you have explored the website and identified the key user flows by navigating to the site like a user would. +2. **Test Improvements**: When asked to improve tests use the Playwright MCP to navigate to the URL and view the page snapshot. Use the snapshot to identify the correct locators for the tests. You may need to run the development server first. +3. **Test Generation**: Once you have finished exploring the site, start writing well-structured and maintainable Playwright tests using TypeScript based on what you have explored. +4. **Test Execution & Refinement**: Run the generated tests, diagnose any failures, and iterate on the code until all tests pass reliably. +5. **Documentation**: Provide clear summaries of the functionalities tested and the structure of the generated tests. + +## Project-Specific Context + +- Tests live in `tests/` directory with `.spec.ts` extension +- Run tests with `npx playwright test --project=desktop` or `--project=mobile` +- Authenticated tests use `tests/helpers.ts`: `createAuthenticatedPage()`, `mockWebSocket()`, `goToChat()` +- Config is in `playwright.config.ts` — desktop (1280x720) and mobile (375x667) projects +- The app uses WebSocket for chat — mock with `mockWebSocket()` helper +- Test the login flow, chat messaging, model selection, settings, and mobile responsiveness + +## Test Writing Standards + +- **Locators**: Use role-based locators (`getByRole`, `getByLabel`, `getByText`) for resilience +- **Assertions**: Use auto-retrying web-first assertions (`await expect(locator).toHaveText()`) +- **Timeouts**: Rely on Playwright's built-in auto-waiting — avoid hard-coded waits +- **Organization**: Group related tests under `test.describe()`, use `beforeEach` for common setup +- **Steps**: Use `test.step()` to group interactions for better reporting diff --git a/.github/agents/polyglot-test-generator.agent.md b/.github/agents/polyglot-test-generator.agent.md new file mode 100644 index 0000000..334ade7 --- /dev/null +++ b/.github/agents/polyglot-test-generator.agent.md @@ -0,0 +1,85 @@ +--- +description: 'Orchestrates comprehensive test generation using Research-Plan-Implement pipeline. Use when asked to generate tests, write unit tests, improve test coverage, or add tests.' +name: 'Polyglot Test Generator' +--- + +# Test Generator Agent + +You coordinate test generation using the Research-Plan-Implement (RPI) pipeline. You are polyglot - you work with any programming language. + +## Pipeline Overview + +1. **Research** - Understand the codebase structure, testing patterns, and what needs testing +2. **Plan** - Create a phased test implementation plan +3. **Implement** - Execute the plan phase by phase, with verification + +## Workflow + +### Step 1: Clarify the Request + +First, understand what the user wants: +- What scope? (entire project, specific files, specific classes) +- Any priority areas? +- Any testing framework preferences? + +If the request is clear (e.g., "generate tests for this project"), proceed directly. + +### Step 2: Research Phase + +Call the `polyglot-test-researcher` subagent to analyze the codebase: + +``` +runSubagent({ + agent: "polyglot-test-researcher", + prompt: "Research the codebase at [PATH] for test generation. Identify: project structure, existing tests, source files to test, testing framework, build/test commands." +}) +``` + +The researcher will create `.testagent/research.md` with findings. + +### Step 3: Planning Phase + +Call the `polyglot-test-planner` subagent to create the test plan: + +``` +runSubagent({ + agent: "polyglot-test-planner", + prompt: "Create a test implementation plan based on the research at .testagent/research.md. Create phased approach with specific files and test cases." +}) +``` + +The planner will create `.testagent/plan.md` with phases. + +### Step 4: Implementation Phase + +Read the plan and execute each phase by calling the `polyglot-test-implementer` subagent: + +``` +runSubagent({ + agent: "polyglot-test-implementer", + prompt: "Implement Phase N from .testagent/plan.md: [phase description]. Ensure tests compile and pass." +}) +``` + +Call the implementer ONCE PER PHASE, sequentially. Wait for each phase to complete before starting the next. + +### Step 5: Report Results + +After all phases are complete: +- Summarize tests created +- Report any failures or issues +- Suggest next steps if needed + +## State Management + +All state is stored in `.testagent/` folder in the workspace: +- `.testagent/research.md` - Research findings +- `.testagent/plan.md` - Implementation plan +- `.testagent/status.md` - Progress tracking (optional) + +## Important Rules + +1. **Sequential phases** - Always complete one phase before starting the next +2. **Polyglot** - Detect the language and use appropriate patterns +3. **Verify** - Each phase should result in compiling, passing tests +4. **Don't skip** - If a phase fails, report it rather than skipping diff --git a/.github/agents/refine-issue.agent.md b/.github/agents/refine-issue.agent.md new file mode 100644 index 0000000..c3c6d1a --- /dev/null +++ b/.github/agents/refine-issue.agent.md @@ -0,0 +1,35 @@ +--- +description: 'Refine the requirement or issue with Acceptance Criteria, Technical Considerations, Edge Cases, and NFRs' +name: 'Refine Requirement or Issue' +tools: [ 'list_issues','githubRepo', 'search', 'add_issue_comment','create_issue','create_issue_comment','update_issue','delete_issue','get_issue', 'search_issues'] +--- + +# Refine Requirement or Issue Chat Mode + +When activated, this mode allows GitHub Copilot to analyze an existing issue and enrich it with structured details including: + +- Detailed description with context and background +- Acceptance criteria in a testable format +- Technical considerations and dependencies +- Potential edge cases and risks +- Expected NFR (Non-Functional Requirements) + +## Steps to Run +1. Read the issue description and understand the context. +2. Modify the issue description to include more details. +3. Add acceptance criteria in a testable format. +4. Include technical considerations and dependencies. +5. Add potential edge cases and risks. +6. Provide suggestions for effort estimation. +7. Review the refined requirement and make any necessary adjustments. + +## Usage + +To activate Requirement Refinement mode: + +1. Refer an existing issue in your prompt as `refine ` +2. Use the mode: `refine-issue` + +## Output + +Copilot will modify the issue description and add structured details to it. diff --git a/.github/agents/security-reviewer.agent.md b/.github/agents/security-reviewer.agent.md new file mode 100644 index 0000000..28b8322 --- /dev/null +++ b/.github/agents/security-reviewer.agent.md @@ -0,0 +1,68 @@ +--- +name: 'Security Reviewer' +description: 'OWASP Top 10, Zero Trust, and LLM security review specialist' +model: GPT-5 +tools: ['codebase', 'edit/editFiles', 'search', 'problems'] +--- + +# Security Reviewer + +Prevent production security failures through comprehensive security review. + +## Your Mission + +Review code for security vulnerabilities with focus on OWASP Top 10, Zero Trust principles, and AI/ML security (LLM-specific threats). + +## Step 0: Create Targeted Review Plan + +1. **Code type?** — Web API → OWASP Top 10 · AI/LLM integration → OWASP LLM Top 10 · Authentication → Access control, crypto +2. **Risk level?** — High: auth, WebSocket, admin · Medium: user data, external APIs · Low: UI components, utilities +3. **Select 3-5 most relevant check categories** based on context + +## Step 1: OWASP Top 10 Review + +- **A01 Broken Access Control**: Enforce least privilege, deny by default, validate user rights per resource +- **A02 Cryptographic Failures**: Strong algorithms (Argon2/bcrypt), HTTPS only, never hardcode secrets +- **A03 Injection**: Parameterized queries, sanitize command-line input, prevent XSS with DOMPurify +- **A05 Security Misconfiguration**: Disable debug in prod, set CSP/HSTS/X-Content-Type-Options headers +- **A07 Auth Failures**: Regenerate session IDs on login, HttpOnly/Secure/SameSite cookies, rate limiting +- **A10 SSRF**: Validate all incoming URLs against allowlists, block internal IP ranges + +## Step 2: LLM Security (OWASP LLM Top 10) + +- **LLM01 Prompt Injection**: Sanitize user input before passing to LLM, constrain response scope +- **LLM06 Information Disclosure**: Remove PII from context, filter sensitive output + +## Step 3: Zero Trust + +- Never trust, always verify — even internal APIs require auth tokens and request validation + +## Project-Specific Security Areas + +This project has these security-critical areas to review: +- **CSP headers** in `hooks.server.ts` — self + unsafe-inline + ws/wss + GitHub avatars +- **Rate limiting** — 200 req/15min per IP (Map-based) in hooks.server.ts +- **Session cookies** — httpOnly, secure (prod), sameSite: lax +- **XSS prevention** — DOMPurify sanitizes all rendered markdown +- **Message length** — 10,000 chars max (server-enforced) +- **Upload limits** — 10MB/file, 5 files max, extension allowlist +- **SSRF protection** — internal IP range blocklist for custom webhook tools +- **Token revalidation** — on WebSocket connect (catches revoked tokens) +- **WebSocket message validation** — `VALID_MESSAGE_TYPES` Set whitelist + +## Output: Code Review Report + +```markdown +# Security Review: [Component] +**Ready for Production**: [Yes/No] +**Critical Issues**: [count] + +## Priority 1 (Must Fix) ⛔ +- [specific issue with fix] + +## Priority 2 (Should Fix) ⚠️ +- [issue with recommendation] + +## Priority 3 (Consider) 💡 +- [improvement suggestion] +``` diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 76d586d..73c6329 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,7 +13,7 @@ Self-hosted multi-model AI chat platform powered by the official `@github/copilo - **Real-time**: WebSocket via custom `server.js` entry, per-user `CopilotClient` lifecycle - **Auth**: GitHub Device Flow only (no client secret, no redirect URI) - **Session**: Express sessions bridged to SvelteKit via `x-session-id` header in `hooks.server.ts` -- **Skills**: `.github/skills/` directory with `SKILL.md` definitions, discovered server-side and passed to the SDK via `skillDirectories` +- **Skills**: `skills/` directory with `SKILL.md` definitions, discovered server-side and passed to the SDK via `skillDirectories` - **Settings**: Persisted server-side via `/api/settings` and mirrored in `localStorage` - **Deployment**: Docker container → Azure Container Apps via `azd up` @@ -29,7 +29,7 @@ Self-hosted multi-model AI chat platform powered by the official `@github/copilo | Language | TypeScript 5.7 (strict mode, ES2022) | | Framework | SvelteKit 5 with `adapter-node` | | Reactivity | Svelte 5 runes ($state, $derived, $effect, $props) | -| AI Engine | `@github/copilot-sdk` ^1.0.0 (client `mode: "empty"`) | +| AI Engine | `@github/copilot-sdk` ^0.1.32 | | WebSocket | `ws` ^8.18 via custom server.js | | Markdown | `marked` + `dompurify` + `highlight.js` (npm, bundled by Vite) | | Security | Custom CSP/HSTS in hooks.server.ts, rate limiting, DOMPurify | @@ -43,7 +43,7 @@ Self-hosted multi-model AI chat platform powered by the official `@github/copilo ``` server.js # Custom entry: HTTP + express-session + WebSocket + SvelteKit handler -.github/skills/ # SDK skill definitions (currently 1 skill) +skills/ # SDK skill definitions (currently 1 skill) └── git-commit/SKILL.md # Built-in git commit workflow skill tests/ # Playwright E2E suites and helpers svelte.config.js # SvelteKit config (adapter-node) @@ -179,8 +179,6 @@ src/ | `VAPID_PRIVATE_KEY` | No | — | VAPID private key for web push | | `VAPID_SUBJECT` | No | — | VAPID subject (mailto: or https: URL) | | `PUSH_STORE_PATH` | No | ./data/push-subscriptions | Directory for push subscription storage | -| `ENABLE_REMOTE_SESSIONS` | No | true | Enable cloud/remote session publishing on the SDK client. Sessions still need per-session `remoteSession: "export"|"on"` opt-in. Set to `false` to hard-disable. | -| `COPILOT_CLIENT_MODE` | No | empty | SDK client mode: `empty` (multi-user safe; features re-enabled per session via `buildEmptyModeSessionDefaults()`) or `copilot-cli` (full ambient capabilities) | ## Build & Run @@ -213,7 +211,7 @@ npx playwright test - Model defaults to `gpt-4.1` if not specified - Permission hooks: `approveAll` in autopilot mode, interactive prompts in other modes - Settings sync through `/api/settings`: authenticated users get a server-side source of truth, while the client mirrors sanitized data in `localStorage` -- Skills are discovered from `.github/skills/*/SKILL.md`, exposed via `/api/skills`, and passed into SDK sessions through `skillDirectories` +- Skills are discovered from `skills/*/SKILL.md`, exposed via `/api/skills`, and passed into SDK sessions through `skillDirectories` - Custom tools use webhook HTTP calls with SSRF protection - The SDK's `@github/copilot` CLI needs `node:sqlite` which ships with Node 24 - Unit tests live next to source as `*.test.ts`; Playwright E2E suites live in `tests/` diff --git a/.github/skills/.gitkeep b/.github/skills/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/.github/workflows/check-pr-target.yml b/.github/workflows/check-pr-target.yml index b4acf54..984f9d1 100644 --- a/.github/workflows/check-pr-target.yml +++ b/.github/workflows/check-pr-target.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Reject PR targeting unsupported branch - uses: actions/github-script@v9 + uses: actions/github-script@v8 with: script: | const allowedBases = new Set(['main', 'master']); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7310b00..bfdc2a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,27 +13,22 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true -env: - NODE_VERSION: '24' - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - jobs: check: runs-on: ubuntu-latest - timeout-minutes: 10 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version: '24' cache: 'npm' - run: npm ci - name: Security audit - # Fail CI on moderate+ advisories — no continue-on-error - run: npm audit --audit-level=moderate + run: npm audit --audit-level=high + continue-on-error: true - name: Build server (required for svelte-check) run: npm run build:server @@ -50,95 +45,41 @@ jobs: - name: Run unit tests with coverage run: npm run test:unit:coverage - - name: Smoke test — start server and verify health - env: - PORT: '3099' - GITHUB_CLIENT_ID: smoke-test-id - SESSION_SECRET: smoke-test-secret-min-32-chars-long - NODE_ENV: production - SESSION_STORE_PATH: /tmp/ci-sessions - SETTINGS_STORE_PATH: /tmp/ci-settings - CHAT_STATE_PATH: /tmp/ci-chat-state - PUSH_STORE_PATH: /tmp/ci-push - run: | - node server.js & - SERVER_PID=$! - - # Wait for server to be ready (max 15s) - for i in $(seq 1 30); do - if curl -sf http://localhost:3099/health > /dev/null 2>&1; then - echo "✅ Server is healthy" - curl -s http://localhost:3099/health | jq . - kill $SERVER_PID 2>/dev/null || true - exit 0 - fi - sleep 0.5 - done - - echo "❌ Server failed to start within 15s" - kill $SERVER_PID 2>/dev/null || true - exit 1 - e2e: needs: check runs-on: ubuntu-latest - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - project: [desktop, mobile] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: ${{ env.NODE_VERSION }} + node-version: '24' cache: 'npm' - run: npm ci - - name: Build - run: npm run build - - - name: Get Playwright version - id: playwright-version - run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" - - - name: Cache Playwright browsers - id: playwright-cache - uses: actions/cache@v6 - with: - path: ~/.cache/ms-playwright - key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }} - - name: Install Playwright browsers - if: steps.playwright-cache.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium - - name: Install Playwright OS dependencies - if: steps.playwright-cache.outputs.cache-hit == 'true' - run: npx playwright install-deps chromium - - - name: Run Playwright tests (${{ matrix.project }}) - run: npx playwright test --project=${{ matrix.project }} --reporter=github,html + - name: Run Playwright tests + run: npx playwright test --project=desktop env: PORT: '3001' GITHUB_CLIENT_ID: test-client-id - SESSION_SECRET: test-secret-for-playwright-min-32 + SESSION_SECRET: test-secret-for-playwright NODE_ENV: development - name: Upload Playwright report if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: - name: playwright-report-${{ matrix.project }} + name: playwright-report path: playwright-report/ retention-days: 7 commit-lint: if: github.event_name == 'pull_request' runs-on: ubuntu-latest - timeout-minutes: 2 steps: - name: Check PR title follows conventional commits env: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..291f7af --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,33 @@ +name: CodeQL + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + schedule: + - cron: '0 6 * * 1' + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + security-events: write + contents: read + strategy: + matrix: + language: [javascript-typescript] + steps: + - uses: actions/checkout@v4 + - uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + - uses: github/codeql-action/autobuild@v4 + - uses: github/codeql-action/analyze@v4 + with: + category: '/language:${{ matrix.language }}' diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index acc175c..a2918f7 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -13,7 +13,7 @@ jobs: codespell: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - name: Check spelling with codespell uses: codespell-project/actions-codespell@v2 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..c6abe9d --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,42 @@ +name: Docker Publish + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: + contents: read + packages: write + +jobs: + build-and-push: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore index 30664c6..643fb8b 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,3 @@ bundled-session-store.db-wal coverage/ *.pem *.key -src/lib/types/*.js -src/lib/types/*.js.map diff --git a/.release-please-manifest.json b/.release-please-manifest.json index a5d1cf2..d4f6f29 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "2.2.0" + ".": "3.0.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 0837bcc..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,21 +0,0 @@ -# Changelog - -## [2.2.0](https://github.com/devartifex/copilot-unleashed/compare/v2.1.0...v2.2.0) (2026-07-07) - - -### Features - -* cloud session creation UI in SessionsSheet ([5c4d61a](https://github.com/devartifex/copilot-unleashed/commit/5c4d61a28a7987afc371304811216f654b6a337a)) -* remote session UI — settings panel, remote URL banner, new-session wiring ([d6e6b5d](https://github.com/devartifex/copilot-unleashed/commit/d6e6b5d2d66e9216dca28e2fc05c20b0628c1b30)) -* **sdk:** upgrade @github/copilot-sdk to 1.0.0 stable ([eeb0a1c](https://github.com/devartifex/copilot-unleashed/commit/eeb0a1cb15573e3ba9f3c833442992c0486b9462)) -* **server:** empty client mode, cloud sessions, remote toggle, tool failure surfacing ([bf1e9f7](https://github.com/devartifex/copilot-unleashed/commit/bf1e9f75d7ab8eb3add7507e48e611d0541939b5)) -* silent resume via suppressResumeEvent on auto re-attach ([b2557d1](https://github.com/devartifex/copilot-unleashed/commit/b2557d1edc4800477b09d0c60b7ca5c00ec529c0)) -* UBB billing terminology — AI Credits (AIC) replaces premium requests in UI ([4f03591](https://github.com/devartifex/copilot-unleashed/commit/4f035912229ddcd82d82e8ad50bdd55a38f9df5d)) -* upgrade Copilot SDK 1.0.0 + cloud/remote sessions + robust UI/testing ([ed8a03c](https://github.com/devartifex/copilot-unleashed/commit/ed8a03c6a3eb4a6bb691e5f53b76e034a405b6db)) - - -### Bug Fixes - -* address copilot review findings for safety and branch validation ([8525f99](https://github.com/devartifex/copilot-unleashed/commit/8525f997f3438186c81b13233f6d6dfc894c4d91)) -* **deps:** resolve all security advisories, TTS sanitization, and test fixes ([#217](https://github.com/devartifex/copilot-unleashed/issues/217)) ([35b2739](https://github.com/devartifex/copilot-unleashed/commit/35b27393681f221b16c1c55f881c420d682c9906)) -* no hardcoded gpt-4.1 default — SDK picks default model, retry on unavailable model, reset stale saved model ([7d9a874](https://github.com/devartifex/copilot-unleashed/commit/7d9a874da176a938fe49e9920111c2cd9a96a876)) diff --git a/Dockerfile b/Dockerfile index 845ccf8..c470b8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,4 @@ FROM node:24-slim AS builder -RUN npm install -g npm@latest WORKDIR /app COPY package.json package-lock.json ./ COPY scripts/ scripts/ @@ -12,49 +11,28 @@ RUN npm run build \ && if [ -f bundled-session-store.db ]; then cp bundled-session-store.db /tmp/copilot-config/session-store.db; fi FROM node:24-slim - -# Stable infra layer — cached independently of source code changes -RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl gnupg git \ - && install -m 0755 -d /etc/apt/keyrings \ - && curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \ - && chmod a+r /etc/apt/keyrings/docker.asc \ - && . /etc/os-release \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian ${VERSION_CODENAME} stable" > /etc/apt/sources.list.d/docker.list \ - && apt-get update \ - && apt-get install -y --no-install-recommends docker-ce-cli \ - && rm -rf /var/lib/apt/lists/* \ - && npm install -g @github/copilot \ - && mkdir -p /home/node/.copilot/session-state /data/sessions /data/settings /data/chat-state /data/push-subscriptions /data/copilot-home \ - && chown -R node:node /home/node /data - -ENV NODE_ENV=production -ENV PORT=3000 -ENV HOME=/home/node -ENV COPILOT_CONFIG_DIR=/home/node/.copilot - +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git && rm -rf /var/lib/apt/lists/* WORKDIR /app -# start.sh changes rarely — keep it above volatile app layers -COPY --chown=node:node scripts/start.sh /app/start.sh -RUN chmod +x /app/start.sh - -# App artifacts — rebuild only when source or deps change COPY --from=builder /app/node_modules node_modules/ COPY --from=builder /app/build build/ COPY --from=builder /app/dist dist/ COPY --from=builder /app/server.js ./ COPY package.json ./ -# Customization files for the scanner (instructions, prompts, agents, skills) -COPY --from=builder /app/.github/copilot-instructions.md .github/copilot-instructions.md -COPY --from=builder /app/.github/instructions/ .github/instructions/ -COPY --from=builder /app/.github/prompts/ .github/prompts/ -COPY --from=builder /app/.github/agents/ .github/agents/ -COPY --from=builder /app/.github/skills/ .github/skills/ +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOME=/home/node + +RUN npm install -g @github/copilot +RUN mkdir -p /home/node/.copilot/session-state /data/sessions /data/settings /data/chat-state /data/push-subscriptions /data/copilot-home && chown -R node:node /home/node /data # Copy bundled CLI session data if it was prepared with scripts/bundle-sessions.mjs COPY --from=builder --chown=node:node /tmp/copilot-config/ /home/node/.copilot/ +ENV COPILOT_CONFIG_DIR=/home/node/.copilot + +COPY --chown=node:node scripts/start.sh /app/start.sh +RUN chmod +x /app/start.sh EXPOSE 3000 USER node diff --git a/README.md b/README.md index a131271..0783505 100644 --- a/README.md +++ b/README.md @@ -1,166 +1,172 @@ -

- Copilot Unleashed logo -

- -

Copilot Unleashed

- -

- Every Copilot model. One login. Any device. Your server. -

+# Copilot Unleashed

Latest Release CI - MIT License - Copilot SDK v1.0.0-beta.8 + Node ≥24 + TypeScript + Svelte 5 Docker - WCAG 2.2 AA accessible - Last Commit - Stars + Azure Container Apps + MIT License + Built with GitHub Copilot

-

- Self-hosted multi-model AI chat built on the official @github/copilot-sdk.
- Autopilot agents · live reasoning traces · native GitHub tools · SDK-native customizations for agents, skills, prompts, instructions, and MCP servers · voice input & read aloud. -

+**Every Copilot model. One login. Any device. Your server.** + +The only open-source web UI built on the official [`@github/copilot-sdk`](https://github.com/github/copilot-sdk). Self-host a ChatGPT-class experience powered by your GitHub Copilot subscription — with autopilot agents, live reasoning traces, native GitHub tools, and persistent sessions that sync between the CLI and the browser.

Autopilot agent — reads a GitHub issue, implements the feature, runs tests, and opens a PR autonomously

Autopilot reads issue #88, implements the fix, runs tests, and opens a PR — zero intervention.

+

+ Extended reasoning — live thinking trace from Claude Opus 4.6 +    + Mobile chat — touch-optimized dark UI +

+

Live reasoning traces on desktop · Touch-optimized mobile UI

+ > Independent project — not affiliated with GitHub. MIT licensed. --- -## Features +## Why this exists + +The GitHub Copilot CLI is powerful, but it's stuck in your terminal. This project wraps the same official SDK in a web UI you can reach from any device — phone, tablet, laptop — with features the CLI doesn't have: persistent sessions, a visual plan editor, file and image attachments, custom webhook tools, and real-time streaming with a dark, touch-friendly interface. + +Your Copilot subscription already gives you access to Claude Opus 4.6, GPT-5.4, Gemini 3 Pro, and more through one account. This app lets you use them all from anywhere, on your own server, without handing your data to another SaaS. + +--- + +## What you get - **Every Copilot model** — Claude Opus 4.6, GPT-5.4, Gemini 3 Pro, Claude Sonnet 4.6, and more — switch mid-conversation, keep full history - **Autopilot agents** — plan, code, run tests, and open PRs autonomously with live tool execution -- **Remote session publishing** — opt sessions into being visible on github.com / Mobile via the Remote Sessions setting (Off / Export / Full remote); a banner links to the live session on GitHub. Server-side toggle: `ENABLE_REMOTE_SESSIONS` (default on); per-session opt-in still required. -- **Cloud sessions** — create sessions that run on GitHub's cloud agent against any repository (owner/repo/branch form in the Sessions panel) -- **Resume last session** — `GET /api/sessions/last` returns metadata for the user's most recent local session for one-tap continue-on-any-device flows -- **Extended thinking** — live reasoning traces with collapsible "Thinking…" blocks -- **Voice input** — speech-to-text via Web Speech API; mic button replaces send when input is empty (ChatGPT-style UX) — toggle in Settings -- **Read aloud** — text-to-speech on any assistant message; markdown-aware sentence chunking with configurable speed — toggle in Settings -- **SDK-native customizations** — agents, skills, prompts, instructions, and MCP servers — configure in `~/.copilot/`, toggle from the UI ([details ↓](#customizations)) +- **Extended thinking** — live reasoning traces from Claude Opus 4.6 and Claude Sonnet 4.6 with collapsible "Thinking…" blocks - **Native GitHub tools** — issues, PRs, code search, repos, Actions — built in via the GitHub MCP server -- **Image & file attachments** — drop images, code, CSVs, or directories with `@` autocomplete; paste images from clipboard; vision models analyze images inline +- **Custom MCP servers** — plug in any MCP-compatible server with per-server headers, tool filtering, and timeout control +- **Custom webhook tools** — connect Jira, Slack, databases, or internal APIs as callable tools +- **Image vision** — attach images alongside code and documents; vision-capable models analyze them inline +- **File & directory attachments** — drop in code files, images, CSVs, or whole directories with `@` mention autocomplete - **Issue & PR references** — type `#` to search and reference GitHub issues/PRs across all your repos -- **Slash commands** — type `/` to access actions like `/run` (shell commands), `/settings`, `/sessions`, and `/status` directly from chat -- **BYOK (Bring Your Own Keys)** — connect your own API keys for additional model providers; keys encrypted at rest with AES-256-GCM (opt-in via `BYOK_ENABLED`) -- **Elicitation** — interactive permission prompts and user input requests from the SDK, with configurable auto-approve in autopilot mode -- **Persistent sessions** — resume any conversation on any device; chat state survives browser close via server-side storage with cold resume -- **CLI ↔ Browser sync** — sessions started in the Copilot CLI appear in the browser and vice versa; full conversation history loaded from CLI's `session-store.db` ([details ↓](#cli--browser-sync)) -- **MCP OAuth tokens** — OAuth-authenticated MCP servers auto-inject tokens from the CLI token store with periodic refresh -- **Push notifications** — Web Push alerts when the browser is closed; full PWA support -- **Plan & Fleet mode** — editable execution plans with disk sync; multi-agent parallel execution -- **Quota tracking** — premium request usage, remaining balance, and reset date -- **Accessible & responsive** — WCAG 2.2 AA compliant: semantic landmarks, keyboard navigation, no keyboard traps (off-screen panels use `visibility:hidden`), visible focus rings, screen-reader labels, sufficient colour contrast, and reduced-motion support across all views and viewports +- **Persistent sessions** — resume any conversation on any device with full checkpoint history; chat state survives browser close via server-side storage with cold resume from disk +- **CLI ↔ Browser sync** — sessions started in the Copilot CLI appear in the browser and vice versa, with automatic filesystem watcher and Docker bind mount for real-time sync +- **Push notifications** — Web Push alerts when the browser is closed; triggers on response ready, errors, permission prompts, and user input; full PWA support (iOS 16.4+ requires "Add to Home Screen" from Safari) +- **Plan mode** — agent creates an editable execution plan before acting; bidirectional sync with `plan.md` on disk +- **Fleet mode** — launch multi-agent parallel execution with per-agent status tracking +- **Quota tracking** — see premium request usage, remaining balance, and reset date at a glance +- **Mobile-first dark UI** — WCAG AA accessible, touch-optimized, reduced-motion support - **Self-hosted** — your data never leaves your server; deploy with Docker or `azd up` --- -## Customizations +## What people do with it + +**Build software by talking.** Switch to autopilot, describe what you want, walk away. The agent plans, writes code, runs tests, opens a PR. + +> *"Add rate-limiting middleware to the API and write integration tests"* → done. *"Refactor the payment service to handle retries with exponential backoff"* → done. + +**Analyze anything.** Drop a CSV, a screenshot, a codebase. Ask questions in plain language. Vision-capable models read images directly. + +> *"Which product line had the highest return rate last quarter?"* · *"What's wrong with this UI layout?"* (with attached screenshot) -The app mirrors the Copilot SDK's native customization model. Configure once, use everywhere — CLI and browser stay in sync. +**Review PRs from your phone.** Commuting? Ask Copilot to summarize any pull request, flag security issues in the diff, and draft review comments — no laptop needed. -| Type | Location | How to use | -|------|----------|------------| -| **Agents** | `~/.copilot/agents/*.agent.md` | Select in Settings → active for session | -| **Skills** | Discovered by SDK | Toggle in Settings → model can invoke | -| **Prompts** | `~/.copilot/prompts/*.prompt.md` | Type `/name` in chat → autocompletes | -| **Instructions** | `~/.copilot/copilot-instructions.md` | Auto-discovered, shown in Settings | -| **MCP Servers** | `~/.copilot/mcp-config.json` | Toggle in Settings → tools available; OAuth-authenticated servers auto-inject tokens from the CLI token store | +**Compare models on hard problems.** Ask GPT-5.4 for speed, switch to Claude Opus 4.6 for deep reasoning, then Gemini 3 Pro for a different angle. Same conversation, all history preserved. -> All paths also support repo-scoped variants (`.github/agents/`, `.github/prompts/`, `.github/instructions/`, `.github/mcp-config.json`). +**Watch it think.** Enable extended thinking — see the live reasoning trace in a collapsible block before the answer. You see *how* it gets there, not just what it concludes. -Source badges in Settings show where each customization was discovered: **CLI** (SDK runtime), **User** (`~/.copilot`), or **Repo** (`.github/`). +**Connect your own tools.** Define webhook tools or add MCP servers in the settings UI. Copilot calls your Jira, your database, your internal APIs — as part of its agentic workflow. + +> *"Is the auth bug ticket still open? If so, find the related PRs and summarize the discussion"* → calls your project tracker, then searches GitHub. + +**Self-host for personal use.** One `azd up`. Optionally share with a trusted team via `ALLOWED_GITHUB_USERS`. Everyone logs in with their own GitHub account — no shared API keys, no shared context. --- -## Quick Start +## GitHub is the killer feature -**Prerequisites:** [GitHub account with Copilot](https://github.com/features/copilot#pricing) (free tier works) + a [GitHub OAuth App](https://github.com/settings/developers). +Every Copilot model gets native GitHub superpowers — repos, issues, PRs, code search, Actions — all wired in through the GitHub MCP server. No plugins, no tokens to configure, no copy-pasting links. It just knows about your work. -> When creating the OAuth App, set **Homepage URL** to `http://localhost:3000` and leave **Authorization callback URL** blank — the app uses Device Flow, so no callback is needed. +**Spin up a project from an idea — on your phone.** -**1. Set required environment variables** +> *"Create a new public repo called 'invoice-api', scaffold a REST API with JWT auth and a database schema, push the initial commit, and open issues for the billing and PDF export features"* -Copy `.env.example` to `.env` (or create `.env`) and fill in the two required values: +Done. Repo created, code pushed, issues filed — without touching a laptop. -```bash -GITHUB_CLIENT_ID= # From github.com/settings/developers -SESSION_SECRET= # Generate: openssl rand -hex 32 -``` +**Close the loop from idea to pull request.** + +> *"Look at the open issues in my main repo, pick the highest-priority bug, implement a fix, run the tests, and open a PR with a clear description"* + +The agent reads the repo, writes the code, references the issue, links the PR. + +**Cross-repo and org-wide context.** + +> *"Find all repos in my org that still use an end-of-life runtime, summarize what each service does, and draft upgrade issues for each one"* + +**PR reviews from anywhere.** + +> *"Summarize what this PR changes, flag any security concerns in the diff, and draft inline review comments on the riskiest lines"* + +Read that on your commute. Reply, approve, or request changes — without opening VS Code. + +**The difference from other AI tools:** ChatGPT, Claude, and Gemini all work *with* GitHub — you paste in code, you copy out diffs. This works *as* GitHub — the agent creates branches, pushes commits, files PRs, and responds to CI feedback natively, the same way the Copilot CLI does from your terminal, but accessible on any device. + +--- + +## Run it -**2. Run** +You need a [GitHub account with Copilot](https://github.com/features/copilot#pricing) (free tier works) and a [GitHub OAuth App](https://github.com/settings/developers) (30 seconds — just copy the Client ID). -> **Previously cloned?** Pull the latest changes first: -> ```bash -> git fetch origin && git reset --hard origin/master -> ``` +**Docker** (recommended): ```bash -docker compose up --build +echo "GITHUB_CLIENT_ID=" >> .env +echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env +docker-compose up --build ``` -> `npm run dev` is an alias for the command above. +**Node.js 24+:** -Docker is **required** — the `@github/copilot` CLI is installed inside the container and is a runtime dependency of the SDK. The app will not function without it. +```bash +npm install && npm run build && npm start +``` Open [localhost:3000](http://localhost:3000). Log in with GitHub. Done. --- -## Configuration +## Deploy to Azure -| Variable | Required | Default | Purpose | -|----------|:--------:|---------|---------| -| `GITHUB_CLIENT_ID` | **yes** | — | Client ID from your [GitHub OAuth App](https://github.com/settings/developers) | -| `SESSION_SECRET` | **yes** | — | Random secret for cookie encryption — generate with `openssl rand -hex 32` | -| `PORT` | — | `3000` | HTTP server port | -| `ALLOWED_GITHUB_USERS` | — | — | Comma-separated GitHub usernames; omit to allow any authenticated user | -| `BASE_URL` | — | `http://localhost:3000` | Public URL — sets cookie domain and WebSocket origin validation | -| `BYOK_ENABLED` | — | `false` | Set to `true` to enable Bring Your Own Keys panel in Settings | +```bash +azd up +``` -
-All options +That's it. Container Apps, ACR (Basic), Key Vault, managed identity, Log Analytics, and TLS — all provisioned automatically. -| Variable | Default | Purpose | -|----------|---------|---------| -| `NODE_ENV` | `development` | `production` enables secure cookies | -| `TOKEN_MAX_AGE_MS` | `86400000` | Force re-auth interval (24h) | -| `SESSION_POOL_TTL_MS` | `300000` | Session TTL when disconnected (5 min) | -| `MAX_SESSIONS_PER_USER` | `5` | Max concurrent tabs/devices | -| `COPILOT_CONFIG_DIR` | `~/.copilot` | Share with CLI for bidirectional sync | -| `SESSION_STORE_PATH` | `/data/sessions` | Persistent session directory | -| `SETTINGS_STORE_PATH` | `/data/settings` | Per-user settings directory | -| `CHAT_STATE_PATH` | `/data/chat-state` | Persisted chat state | -| `VAPID_PUBLIC_KEY` | — | Push notifications (base64url) | -| `VAPID_PRIVATE_KEY` | — | Push notifications (base64url) | -| `VAPID_SUBJECT` | — | Push subject (`mailto:` or `https:`) | -| `PUSH_STORE_PATH` | `/data/push-subscriptions` | Push subscription storage | -| `ENABLE_REMOTE_SESSIONS` | `true` | Allow sessions to opt into cloud publishing (`remoteSession: "export"\|"on"`) and cloud-agent sessions. Set to `false` to hard-disable. | -| `COPILOT_CLIENT_MODE` | `empty` | SDK client mode. `empty` (recommended for servers) starts with all ambient capabilities off and re-enables only what the app needs. Set to `copilot-cli` to restore full CLI-equivalent behavior. | +### Required parameters -
+`azd up` will prompt for these if not already set: ---- +| Parameter | How to set | Notes | +|-----------|-----------|-------| +| `GITHUB_CLIENT_ID` | `azd env set GITHUB_CLIENT_ID ` | Your GitHub OAuth App client ID | +| `SESSION_SECRET` | auto-generated as `newGuid()` if omitted | 32+ char random string | -## Deploy to Azure +### Deployer IP (required for ACR push access) + +The infrastructure uses a **Basic SKU ACR** with a deployer IP allowlist for push access. Set your current public IP before deploying: ```bash azd env set DEPLOYER_IP_ADDRESS "$(curl -s https://api.ipify.org)" azd up ``` -Container Apps, ACR (Basic), Key Vault (RBAC-only), managed identity, Log Analytics, and TLS — all provisioned automatically. `azd up` prompts for `GITHUB_CLIENT_ID` if not already set. - -
-VAPID keys, troubleshooting, and details +The ACR firewall will allow only that IP for push operations. Container Apps pull images via managed identity. For CI/CD pipelines, set `DEPLOYER_IP_ADDRESS` to the runner's outbound IP in the same way. -**VAPID keys** (optional, for push notifications): +### Optional: VAPID keys for push notifications ```bash node scripts/generate-vapid-keys.mjs @@ -169,69 +175,188 @@ azd env set VAPID_PRIVATE_KEY "" azd env set VAPID_SUBJECT "mailto:you@example.com" ``` -**Troubleshooting:** +### Bootstrapping secrets in Key Vault -- **MANIFEST_UNKNOWN**: Clear stale image tag with `azd env set SERVICE_WEB_IMAGE_NAME "" && azd up` -- **ACR 403 Forbidden**: Re-set deployer IP with `azd env set DEPLOYER_IP_ADDRESS "$(curl -s https://api.ipify.org)" && azd provision` -- **Key Vault secret missing**: Ensure `GITHUB_CLIENT_ID` is set, then `azd provision` +`azd provision` stores `GITHUB_CLIENT_ID`, `SESSION_SECRET`, and (optionally) VAPID keys as secrets in Azure Key Vault. The Container App reads them at runtime via the managed identity — no secrets in environment variables or container image. -
+### Troubleshooting `azd up` ---- +
+Container App fails with MANIFEST_UNKNOWN image tag + +If provisioning fails mid-run, the `.azure//.env` may hold a stale `SERVICE_WEB_IMAGE_NAME` pointing to a tag that no longer exists in a newly-recreated ACR. Clear it before retrying: + +```bash +azd env set SERVICE_WEB_IMAGE_NAME "" +azd up +``` -## CLI ↔ Browser Sync +`azd provision` will deploy with the placeholder image; `azd deploy` pushes the real image immediately after. -Sessions started in the Copilot CLI appear in the browser and vice versa. The app shares `~/.copilot/session-state/` with the CLI — plan edits sync in both directions automatically. +
-```yaml -# docker-compose.yml — enable sync -volumes: - - ~/.copilot:/home/node/.copilot +
+ACR push fails with 403 Forbidden / IP not allowed + +Set your public IP and re-provision to open the ACR firewall: + +```bash +azd env set DEPLOYER_IP_ADDRESS "$(curl -s https://api.ipify.org)" +azd provision # updates ACR network rules +azd deploy # push succeeds ``` -Push sessions to a remote instance without redeploying: +
+ +
+Container App fails: unable to fetch secret from Key Vault + +This means the Key Vault secret doesn't exist yet (typically on a fresh deploy). Ensure `GITHUB_CLIENT_ID` is set and run `azd provision` again — it will create the secrets in Key Vault as part of infra creation. ```bash -npm run sync:push -- https://your-app.azurecontainerapps.io +azd env set GITHUB_CLIENT_ID "" +azd provision ``` +
+ +--- + +## Config + +| Variable | Required | Default | What it does | +|----------|:--------:|---------|-------------| +| `GITHUB_CLIENT_ID` | yes | — | OAuth App client ID | +| `SESSION_SECRET` | yes | — | Cookie encryption key | +| `PORT` | — | `3000` | Server port | +| `ALLOWED_GITHUB_USERS` | — | — | Restrict access to specific users | +| `BASE_URL` | — | `http://localhost:3000` | Cookie domain + WS origin check | +| `GITHUB_REPO` | — | — | Optional `owner/repo` scope for issue search | +
-How sync works, session bundling, and more +All options + +| Variable | Default | What it does | +|----------|---------|-------------| +| `NODE_ENV` | `development` | `production` enables secure cookies | +| `TOKEN_MAX_AGE_MS` | `86400000` | Force re-auth interval (24h) | +| `SESSION_POOL_TTL_MS` | `300000` | Session TTL when disconnected (5 min) | +| `MAX_SESSIONS_PER_USER` | `5` | Max concurrent tabs/devices per user (evicts oldest when exceeded) | +| `SESSION_STORE_PATH` | `/data/sessions` | Persistent session directory | +| `SETTINGS_STORE_PATH` | `/data/settings` | Per-user settings directory | +| `COPILOT_CONFIG_DIR` | `~/.copilot` | Copilot session-state directory (share with CLI for bidirectional sync) | +| `CHAT_STATE_PATH` | `.chat-state` (dev) / `/data/chat-state` (prod) | Persisted chat state storage | +| `VAPID_PUBLIC_KEY` | — | VAPID public key (base64url) — required for push notifications | +| `VAPID_PRIVATE_KEY` | — | VAPID private key (base64url) — required for push notifications | +| `VAPID_SUBJECT` | `mailto:admin@example.com` | VAPID subject identifier | +| `PUSH_STORE_PATH` | `/data/push-subscriptions` | Push subscription storage path | -The SDK stores each session as `~/.copilot/session-state/{uuid}/` with `workspace.yaml`, `plan.md`, and checkpoint files. When you resume a session from the browser, the SDK restores conversation history automatically — and if the CLI's `session-store.db` is available, full turn-by-turn history is loaded from it for a richer resume experience. For disk-only sessions (e.g. bundled into Docker), the app falls back to reading checkpoint files directly and injecting them as context. +
-**Bundle sessions at build time** (Azure / CI): +### Generate VAPID keys (for push notifications) ```bash -npm run bundle-sessions # snapshots ~/.copilot sessions -azd up # auto-runs via predeploy hook +node scripts/generate-vapid-keys.mjs ``` -The Sessions panel auto-refreshes every 30 seconds. Use `COPILOT_CONFIG_DIR` to customize the session-state path. +Copy the output into your `.env` file. Push notifications require all three VAPID variables (`VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT`). - +--- + +## CLI ↔ Browser session sync + +Copilot Unleashed and the GitHub Copilot CLI share the same session-state directory (`~/.copilot/session-state/`). By default, the app reads from the same location the CLI uses — so any session started in the terminal is available in the browser the moment you open the Sessions panel. + +> **Note:** When running via Docker (`npm run dev`), the `docker-compose.yml` mounts `~/.copilot` read-only into the container. + +### How it works + +The `@github/copilot-sdk` stores each session as a folder on disk: + +``` +~/.copilot/session-state/{session-uuid}/ + workspace.yaml ← project metadata (cwd, repo, branch, summary) + plan.md ← living task list updated as the agent works + checkpoints/ + index.md ← checkpoint table of contents + 001_*.md ← compressed conversation snapshots + 002_*.md + … +``` + +When you resume a session from the browser, the SDK's native `resumeSession()` restores the full conversation history and checkpoint context automatically. If the session is only available on disk (e.g. bundled into a Docker image without an active SDK index), the app falls back to reading `workspace.yaml`, `plan.md`, and the last three checkpoint files directly and injecting them as context into a new session — so nothing is lost. + +### Bidirectional plan sync + +Plan changes flow in both directions between the CLI and the browser: + +- **CLI → Browser**: When you resume a session, the filesystem `plan.md` is injected into the agent's context as a system message, so the agent knows the current plan even if it was last modified in the terminal. +- **Browser → CLI**: When the agent updates the plan during a browser session, the change is automatically written back to `plan.md` on disk. The next time you run `copilot resume` in the terminal, the CLI picks up the latest plan. + +This sync is always active and requires no configuration — as long as the CLI and Copilot Unleashed share the same `~/.copilot/session-state/` directory (which is the default when running locally). In Docker, you need a bind-mount to enable it (see below). -### Remote session publishing +### Sessions panel -A chat can opt into being **published** to github.com / Copilot Mobile. Pick the default in **Settings → Remote Sessions** (applies to new sessions, or hit "Apply to current session"): +The Sessions panel (bottom-left icon) lets you: -| Mode | Effect | -|---|---| -| `"off"` *(default)* | Local only. Nothing leaves the server. | -| `"export"` | Read-only mirror — session events stream to GitHub so it shows up on github.com/copilot and Mobile in monitor mode. | -| `"on"` | Full remote-steerable — the session is steerable from github.com / Mobile as well as from this app. | +- Browse all sessions grouped by repository +- See metadata badges — branch, checkpoint count, plan indicator +- Preview a session before resuming: checkpoint timeline, full `plan.md` content, project path +- Search and filter by title, repository, branch, or directory +- Resume any session with one tap, on any device -This is sent over WebSocket as `{ type: "new_session", remoteSession: "on", ... }` and threaded through to the SDK's `sessionConfig.remoteSession`; runtime toggling uses `{ type: "remote_toggle", mode }` (SDK `session.rpc.remote`). When GitHub returns the session URL, the app shows a banner with an "Open on GitHub" link. The server-wide kill switch is `ENABLE_REMOTE_SESSIONS=false`. +### Custom session-state directory -### Cloud sessions (GitHub cloud agent) +If you want to use a separate directory (e.g. a shared network path or a custom mount in Docker): -From the Sessions panel, **New cloud session** creates a session that runs on GitHub's cloud agent infrastructure instead of locally — give it a repository (`owner` / `name` / optional `branch`) and the agent works against that repo. Sent as `{ type: "new_cloud_session", repository }`; the session ID is assigned by GitHub. Requires `ENABLE_REMOTE_SESSIONS` and cloud-agent entitlements on your account. +```bash +COPILOT_CONFIG_DIR=/data/copilot-state +``` + +The CLI and Copilot Unleashed will read from and write to the same path. Sessions started in either interface appear in both. + +### Docker / Azure deployment + +When deploying to a container, you have several options for session availability: + +**Option 1: Bind-mount (Docker Compose, local development)** + +```yaml +# docker-compose.yml +volumes: + - ~/.copilot:/home/node/.copilot # read-write: full bidirectional sync +``` + +**Option 2: Bundle sessions at build time (Azure / CI)** + +Run `npm run bundle-sessions` before building the Docker image. This snapshots your local CLI sessions into the image. When deploying with `azd up`, this happens automatically via the `predeploy` hook in `azure.yaml`. + +```bash +npm run bundle-sessions # snapshots ~/.copilot sessions into bundled-sessions/ +azd up # auto-runs bundle-sessions before docker build +``` + +> **Note:** CI/CD builds (GitHub Actions) won't include your local sessions since `~/.copilot` isn't available in the runner. Use `azd up` locally or push sessions on-demand (below). + +**Option 3: Push sessions on-demand to a running instance** + +After deploying, push new sessions without redeploying: -> **What's not in this release:** the app does **not** include an in-app browser for *other* remote sessions (the ones running elsewhere on your account) and does **not** let you steer arbitrary remote sessions from this UI — the SDK exposes no public REST endpoint for listing them, and the github.com remote-sessions view talks to an internal API that requires a Copilot bearer integrators can't currently mint. To view all your remote sessions, use github.com or the Copilot Mobile app. PRs welcome once the SDK surfaces a public list API. +```bash +npm run sync:push -- https://your-app.azurecontainerapps.io +``` + +This computes a delta (sessions in local `~/.copilot` but not on remote) and uploads only the new or updated ones. It authenticates using your GitHub token (`gh auth token` or `GH_TOKEN` env var). The remote instance must have `ALLOWED_GITHUB_USERS` set to include your username. + +The sync API (`GET/POST /api/sessions/sync`) is also available programmatically for custom automation. + +### Auto-refresh + +The Sessions panel auto-refreshes every 30 seconds while open, so CLI sessions created in a parallel terminal appear in the browser without manual reload. --- -## How It Works +## How it works ``` Browser ──WebSocket──▶ SvelteKit + server.js ──JSON-RPC──▶ Copilot SDK subprocess @@ -246,101 +371,64 @@ Browser ──WebSocket──▶ SvelteKit + server.js ──JSON-RPC──▶ C --- -## Security +## Auth & Security -Device Flow OAuth (same as GitHub CLI). Tokens are server-side only, never sent to the browser. Sessions are encrypted, rate-limited, and validated against GitHub's API on every WebSocket connect. +Device Flow OAuth (same as GitHub CLI). No client secret needed. Tokens are server-side only, never sent to the browser. Sessions are encrypted, rate-limited, and validated against GitHub's API on every WebSocket connect. -> [!IMPORTANT] -> **Self-hosters:** by default any GitHub user who completes device flow can sign in. Set `ALLOWED_GITHUB_USERS=your-login[,another-login]` to restrict access to a named allowlist. This is the single most important hardening step for a publicly-reachable deployment. +Scopes: `copilot` (API access) + `read:user` (avatar) + `repo` (SDK tools need it — same as the CLI).
Full security details - CSP headers, CSRF protection, HSTS, X-Frame-Options DENY -- Permissions-Policy: microphone scoped to same origin (`self`) — no third-party access -- Rate limiting: 200 req / 15 min per IP (HTTP) + 30 msg / min per WebSocket +- Rate limiting: 200 req / 15 min per IP (HTTP) + 30 msg / min per WebSocket connection - Secure cookies: httpOnly, secure (prod), sameSite: lax - DOMPurify on all rendered markdown -- SSRF blocklist for MCP server URLs and OAuth token endpoints (IPv4 + IPv6 internal ranges, HTTPS required) +- SSRF blocklist for custom webhook and MCP server URLs (IPv4 + IPv6 internal ranges, HTTPS required) - 10,000 char message limit, 10MB upload limit, extension allowlist -- BYOK keys encrypted at rest with AES-256-GCM - Per-tool permission prompts with 30s auto-deny countdown - Token revalidation on every WebSocket connect +- Structured security event logging +- Optional user allowlist via `ALLOWED_GITHUB_USERS` - CodeQL scanning + secret scanning via GitHub Advanced Security - All API endpoints require GitHub authentication — no anonymous access -- **Azure**: Key Vault (RBAC-only), Basic ACR with deployer IP allowlist, managed identity for pulls +- Periodic token revalidation ensures revoked tokens are caught promptly +- **Azure**: Key Vault (RBAC-only) for secrets management, Basic ACR with deployer IP allowlist, managed identity for registry pull
--- -
-Screenshots - -
- -**Desktop** - - - - - - - - - - - - - - - - - - -
Autopilot agent — reads a GitHub issue, plans, codes, runs tests, and opens a PRExtended reasoning — live collapsible thinking trace
Autopilot agent — issue → PR, zero interventionExtended thinking — live reasoning trace
Code generation with syntax highlightingGitHub Device Flow login screen
Code generation with syntax highlightingGitHub Device Flow login
- -**Tablet (iPad)** - - - - - - - - - - - - - - -
Autopilot agent on tabletCode generation on tabletExtended reasoning on tabletLogin on tablet
AutopilotCode genReasoningLogin
- -**Mobile** - - - - - - - - - - - - - - -
Autopilot agent on mobileCode generation on mobileExtended reasoning on mobileLogin on mobile
AutopilotCode genReasoningLogin
+## Data Persistence -
+All stateful data survives container restarts when volumes are configured correctly. + +| Data | Path (default) | Survives restart? | +|------|----------------|:-:| +| SDK sessions & checkpoints | `COPILOT_CONFIG_DIR` (`~/.copilot`) | ✅ with volume | +| App session metadata | `SESSION_STORE_PATH` (`/data/sessions`) | ✅ with volume | +| Per-user settings | `SETTINGS_STORE_PATH` (`/data/settings`) | ✅ with volume | +| Chat history | `CHAT_STATE_PATH` (`/data/chat-state`) | ✅ with volume | +| Push subscriptions | `PUSH_STORE_PATH` (`/data/push-subscriptions`) | ✅ with volume | + +**Local Docker:** Use a named volume for `/data` and a bind mount for `~/.copilot` (enables CLI ↔ Browser sync). + +```yaml +# docker-compose.yml +volumes: + - app-data:/data # sessions, settings, chat state, push subs + - ~/.copilot:/home/node/.copilot # SDK session-state (shared with CLI) +``` + +**Azure Container Apps:** Uses an EmptyDir volume at `/data`. Data survives container restarts but **not** replica replacement or scaling events. The Bicep infrastructure provisions this automatically via `azd up`. --- -## Built With +## Built with -SvelteKit 5 · Svelte 5 runes · TypeScript 5.7 · Node.js 24 · [`@github/copilot-sdk`](https://github.com/github/copilot-sdk) v1.0.0 · Vite · `ws` · Web Speech API · Vitest · Playwright · Docker · Bicep +SvelteKit 5 · Svelte 5 runes · TypeScript 5.7 · Node.js 24 · `@github/copilot-sdk` · Vite · `ws` · Vitest · Playwright · Docker · Bicep + +--- ## Contributing diff --git a/SECURITY.md b/SECURITY.md index f6200a8..ef07240 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | |---------|--------------------| -| 4.x | :white_check_mark: | -| < 4.0 | :x: | +| 2.x | :white_check_mark: | +| < 2.0 | :x: | ## Reporting a Vulnerability diff --git a/docker-compose.yml b/docker-compose.yml index 3c599e8..c47def3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,6 @@ services: environment: - NODE_ENV=development - BASE_URL=http://localhost:3000 - - DOCKER_HOST=unix:///var/run/docker.sock - SESSION_STORE_PATH=/data/sessions - SETTINGS_STORE_PATH=/data/settings - CHAT_STATE_PATH=/data/chat-state @@ -22,9 +21,6 @@ services: volumes: - copilot-data:/data - ~/.copilot:/home/node/.copilot # sync host CLI sessions into container - - /var/run/docker.sock:/var/run/docker.sock - group_add: - - "991" restart: unless-stopped volumes: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5f65335..b76bfa4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,7 +25,7 @@ Browser (Svelte 5 SPA) | Language | TypeScript 5.7 (strict mode, ES2022) | | Framework | SvelteKit 5 with `adapter-node` | | Reactivity | Svelte 5 runes ($state, $derived, $effect, $props) | -| AI Engine | `@github/copilot-sdk` ^1.0.0 (client `mode: "empty"` by default) | +| AI Engine | `@github/copilot-sdk` ^0.1.32 | | Real-time | WebSocket (`ws` ^8.18) via custom `server.js` entry | | Markdown | `marked` + `dompurify` + `highlight.js` | | Security | Custom CSP/HSTS headers in hooks.server.ts, rate limiting, DOMPurify | @@ -33,7 +33,7 @@ Browser (Svelte 5 SPA) | Build | Vite → `build/` via adapter-node | | Container | Multi-stage Dockerfile (builder + runtime) | | IaC | Bicep (Container Apps, ACR Basic, Key Vault RBAC, Managed Identity, Log Analytics) | -| CI/CD | GitHub Actions (ci.yml + release.yml) | +| CI/CD | GitHub Actions (ci.yml + deploy.yml) | | Testing | Playwright (desktop + mobile viewports) | ## Project Structure @@ -50,22 +50,18 @@ src/ │ │ ├── Banner.svelte # Welcome banner with Copilot icon │ │ ├── ChatInput.svelte # Auto-expanding textarea, file attachments, status bar │ │ ├── ChatMessage.svelte # Message renderer (10 roles: user, assistant, tool, etc.) +│ │ ├── CustomToolsEditor.svelte # Webhook tool CRUD editor │ │ ├── DeviceFlowLogin.svelte # GitHub Device Flow auth UI │ │ ├── EnvInfo.svelte # Environment info (models, tools, context usage) -│ │ ├── FleetProgress.svelte # Fleet execution progress tracker │ │ ├── MessageList.svelte # Scrollable message container with smart auto-scroll -│ │ ├── ModelSheet.svelte # Bottom sheet for model selection │ │ ├── PermissionPrompt.svelte # Tool permission request with countdown │ │ ├── PlanPanel.svelte # Collapsible plan viewer/editor │ │ ├── QuotaDot.svelte # Color-coded quota indicator │ │ ├── ReasoningBlock.svelte # Collapsible "Thinking..." block with shimmer -│ │ ├── SessionPreview.svelte # Session history preview card │ │ ├── SessionsSheet.svelte # Bottom sheet for session history │ │ ├── SettingsModal.svelte # Accordion settings (instructions, tools, agents, quota) │ │ ├── Sidebar.svelte # Slide-out menu (mode, model, reasoning, actions) -│ │ ├── SourceBadge.svelte # Source attribution badge for references │ │ ├── ToolCall.svelte # Animated tool execution with Braille spinner -│ │ ├── TopBar.svelte # Top navigation bar with session controls │ │ └── UserInputPrompt.svelte # Elicitation UI (choices + freeform) │ │ │ ├── stores/ # Svelte 5 rune stores (factory functions) @@ -276,8 +272,8 @@ export function createChatStore(wsStore: WsStore): ChatStore { Discriminated unions on `type` field for all messages: -- **ServerMessage** (79 types): `connected`, `delta`, `tool_start`, `permission_request`, etc. -- **ClientMessage** (32 types): `new_session`, `message`, `set_mode`, `permission_response`, etc. +- **ServerMessage** (34 types): `connected`, `delta`, `tool_start`, `permission_request`, etc. +- **ClientMessage** (19 types): `new_session`, `message`, `set_mode`, `permission_response`, etc. - **ChatMessage** (10 roles): `user`, `assistant`, `tool`, `info`, `warning`, `error`, `intent`, `usage`, `skill`, `subagent` ## Security @@ -363,23 +359,12 @@ All push API endpoints require GitHub authentication. | `CHAT_STATE_PATH` | — | `.chat-state` (dev) / `/data/chat-state` (prod) | Persisted chat state directory | | `PUSH_STORE_PATH` | — | `/data/push-subscriptions` | Push subscription storage | | `COPILOT_CONFIG_DIR` | — | `~/.copilot` | SDK config/session directory (Azure: `/data/copilot-home`) | -| `COPILOT_CLIENT_MODE` | — | `empty` | SDK client mode — `empty` (multi-user safe; app re-enables features per session) or `copilot-cli` (full CLI-equivalent ambient capabilities) | -| `ENABLE_REMOTE_SESSIONS` | — | `true` | Allow remote publishing (`remoteSession`) and cloud-agent sessions; `false` hard-disables | - -### SDK client mode - -The server creates each per-user `CopilotClient` with `mode: "empty"` (SDK 1.0.0): sessions start with no ambient capabilities, and `buildEmptyModeSessionDefaults()` in `src/lib/server/copilot/session.ts` explicitly re-enables what the app needs — all built-in/MCP/custom tools via `ToolSet`, skills, config discovery, host git operations, the session store, on-demand instruction discovery, persistent MCP OAuth tokens, and embedding cache. File hooks, telemetry, and plugins stay off. Set `COPILOT_CLIENT_MODE=copilot-cli` to restore the old behavior. - -### Remote & cloud sessions - -- **Remote publishing** — `new_session` accepts `remoteSession: "off"|"export"|"on"`; `remote_toggle` flips it at runtime via `session.rpc.remote.enable()/disable()`. The remote URL arrives via the SDK `session.info` event (`infoType: "remote"`) and is forwarded as `remote_session_url`; the UI renders a banner linking to github.com. -- **Cloud sessions** — `new_cloud_session` (validated `repository: { owner, name, branch? }`) creates a session with `cloud: { repository }` running on GitHub's cloud agent; the session ID is server-assigned. Handlers: `src/lib/server/ws/message-handlers/cloud-session.ts` and `remote.ts`. ## Deployment - **Docker**: `npm run build` → `node build/index.js` (adapter-node output) - **Azure**: `azd up` → Container Apps + ACR + Managed Identity + monitoring -- **CI/CD**: GitHub Actions — ci.yml (check + build), release.yml (release-please) +- **CI/CD**: GitHub Actions — ci.yml (check + build), deploy.yml (Docker → ACR → ACA) ### Filesystem Persistence diff --git a/docs/screenshots/chat-desktop.png b/docs/screenshots/chat-desktop.png new file mode 100644 index 0000000..be0061e Binary files /dev/null and b/docs/screenshots/chat-desktop.png differ diff --git a/docs/screenshots/chat-mobile.png b/docs/screenshots/chat-mobile.png new file mode 100644 index 0000000..8e44f72 Binary files /dev/null and b/docs/screenshots/chat-mobile.png differ diff --git a/docs/screenshots/login-desktop.png b/docs/screenshots/login-desktop.png index f7544df..b8449e9 100644 Binary files a/docs/screenshots/login-desktop.png and b/docs/screenshots/login-desktop.png differ diff --git a/docs/screenshots/login-ipad.png b/docs/screenshots/login-ipad.png index 4375364..a6056a2 100644 Binary files a/docs/screenshots/login-ipad.png and b/docs/screenshots/login-ipad.png differ diff --git a/docs/screenshots/login-mobile.png b/docs/screenshots/login-mobile.png index 7519495..fbcce28 100644 Binary files a/docs/screenshots/login-mobile.png and b/docs/screenshots/login-mobile.png differ diff --git a/docs/screenshots/usecase-autopilot-desktop.png b/docs/screenshots/usecase-autopilot-desktop.png index 0291993..01f37c4 100644 Binary files a/docs/screenshots/usecase-autopilot-desktop.png and b/docs/screenshots/usecase-autopilot-desktop.png differ diff --git a/docs/screenshots/usecase-autopilot-ipad.png b/docs/screenshots/usecase-autopilot-ipad.png index 0c1c6e6..d48dcc3 100644 Binary files a/docs/screenshots/usecase-autopilot-ipad.png and b/docs/screenshots/usecase-autopilot-ipad.png differ diff --git a/docs/screenshots/usecase-autopilot-mobile.png b/docs/screenshots/usecase-autopilot-mobile.png index 6f7e3bf..22641f6 100644 Binary files a/docs/screenshots/usecase-autopilot-mobile.png and b/docs/screenshots/usecase-autopilot-mobile.png differ diff --git a/docs/screenshots/usecase-autopilot.png b/docs/screenshots/usecase-autopilot.png new file mode 100644 index 0000000..cc17c60 Binary files /dev/null and b/docs/screenshots/usecase-autopilot.png differ diff --git a/docs/screenshots/usecase-code-desktop.png b/docs/screenshots/usecase-code-desktop.png index 1f6fe33..8630e95 100644 Binary files a/docs/screenshots/usecase-code-desktop.png and b/docs/screenshots/usecase-code-desktop.png differ diff --git a/docs/screenshots/usecase-code-ipad.png b/docs/screenshots/usecase-code-ipad.png index 07af287..005f57a 100644 Binary files a/docs/screenshots/usecase-code-ipad.png and b/docs/screenshots/usecase-code-ipad.png differ diff --git a/docs/screenshots/usecase-code-mobile.png b/docs/screenshots/usecase-code-mobile.png index c446c06..7f3c3c2 100644 Binary files a/docs/screenshots/usecase-code-mobile.png and b/docs/screenshots/usecase-code-mobile.png differ diff --git a/docs/screenshots/usecase-reasoning-desktop.png b/docs/screenshots/usecase-reasoning-desktop.png index ea33763..a38b7d4 100644 Binary files a/docs/screenshots/usecase-reasoning-desktop.png and b/docs/screenshots/usecase-reasoning-desktop.png differ diff --git a/docs/screenshots/usecase-reasoning-ipad.png b/docs/screenshots/usecase-reasoning-ipad.png index 1692cba..83cb61a 100644 Binary files a/docs/screenshots/usecase-reasoning-ipad.png and b/docs/screenshots/usecase-reasoning-ipad.png differ diff --git a/docs/screenshots/usecase-reasoning-mobile.png b/docs/screenshots/usecase-reasoning-mobile.png index 6477345..5bdaf23 100644 Binary files a/docs/screenshots/usecase-reasoning-mobile.png and b/docs/screenshots/usecase-reasoning-mobile.png differ diff --git a/docs/screenshots/usecase-reasoning.png b/docs/screenshots/usecase-reasoning.png new file mode 100644 index 0000000..8edff17 Binary files /dev/null and b/docs/screenshots/usecase-reasoning.png differ diff --git a/package-lock.json b/package-lock.json index 8fc57cb..c0705ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,47 +1,49 @@ { "name": "copilot-unleashed", - "version": "2.2.0", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copilot-unleashed", - "version": "2.2.0", + "version": "4.0.0", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@github/copilot-sdk": "^1.0.5", - "@sveltejs/adapter-node": "^5.5.7", - "@sveltejs/kit": "^2.68.0", - "dompurify": "^3.4.11", + "@github/copilot-sdk": "^0.1.32", + "@sveltejs/adapter-node": "^5.5.4", + "@sveltejs/kit": "^2.55.0", + "dompurify": "^3.3.3", "express-session": "^1.18.0", "highlight.js": "^11.11.1", - "lucide-svelte": "^1.0.1", - "marked": "^18.0.5", + "isomorphic-dompurify": "^3.6.0", + "marked": "^17.0.4", "session-file-store": "^1.5.0", - "svelte": "^5.56.4", - "vite": "^8.1.3", + "svelte": "^5.54.1", + "vite": "^8.0.1", "web-push": "^3.6.7", - "ws": "^8.21.0" + "ws": "^8.18.0" }, "devDependencies": { - "@playwright/test": "^1.61.0", - "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@playwright/test": "^1.58.2", + "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/jest-dom": "^6.9.1", - "@types/express-session": "^1.19.0", - "@types/node": "^25.9.3", + "@testing-library/svelte": "^5.3.1", + "@types/dompurify": "^3.2.0", + "@types/express-session": "^1.18.0", + "@types/node": "^25.5.0", "@types/session-file-store": "^1.2.6", "@types/web-push": "^3.6.4", "@types/ws": "^8.5.13", - "@vitest/coverage-v8": "^4.1.9", - "dotenv": "^17.4.2", + "@vitest/coverage-v8": "^4.1.0", "husky": "^9.1.7", - "jsdom": "^29.1.1", - "lint-staged": "^17.0.7", - "sharp": "^0.35.1", - "svelte-check": "^4.6.0", - "typescript": "^6.0.3", - "vitest": "^4.1.3" + "jsdom": "^29.0.1", + "lint-staged": "^16.4.0", + "sharp": "^0.34.5", + "svelte-check": "^4.4.5", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^4.1.0" }, "engines": { "node": ">=24.0.0" @@ -55,56 +57,58 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", - "dev": true, + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", - "dev": true, + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", "license": "MIT", "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1" + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, "node_modules/@asamuzakjp/nwsapi": { "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, "license": "MIT" }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -141,6 +145,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", @@ -169,7 +183,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, "license": "MIT", "dependencies": { "css-tree": "^3.0.0" @@ -182,7 +195,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", - "dev": true, "funding": [ { "type": "github", @@ -199,10 +211,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", - "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", - "dev": true, + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", "funding": [ { "type": "github", @@ -223,10 +234,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", - "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", - "dev": true, + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", "funding": [ { "type": "github", @@ -240,7 +250,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.1" + "@csstools/css-calc": "^3.1.1" }, "engines": { "node": ">=20.19.0" @@ -254,7 +264,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, "funding": [ { "type": "github", @@ -274,10 +283,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.4.tgz", - "integrity": "sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==", - "dev": true, + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", + "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", "funding": [ { "type": "github", @@ -302,7 +310,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, "funding": [ { "type": "github", @@ -313,47 +320,436 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">=20.19.0" + "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "license": "MIT", + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "license": "MIT", + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "license": "MIT", + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, "node_modules/@exodus/bytes": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -368,31 +764,26 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.68", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.68.tgz", - "integrity": "sha512-2VPcTlW0RAEsfeS0Ma2ICCkfXgpxy3NL7+SReR8gzvEEPiokSRf0k5JBPlgMbBEFvocSRcJ01S8KvBm84Dw+Fw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.2.tgz", + "integrity": "sha512-716SIZMYftldVcJay2uZOzsa9ROGGb2Mh2HnxbDxoisFsWNNgZlQXlV7A+PYoGsnAo2Zk/8e1i5SPTscGf2oww==", "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "detect-libc": "^2.1.2" - }, "bin": { "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.68", - "@github/copilot-darwin-x64": "1.0.68", - "@github/copilot-linux-arm64": "1.0.68", - "@github/copilot-linux-x64": "1.0.68", - "@github/copilot-linuxmusl-arm64": "1.0.68", - "@github/copilot-linuxmusl-x64": "1.0.68", - "@github/copilot-win32-arm64": "1.0.68", - "@github/copilot-win32-x64": "1.0.68" + "@github/copilot-darwin-arm64": "1.0.2", + "@github/copilot-darwin-x64": "1.0.2", + "@github/copilot-linux-arm64": "1.0.2", + "@github/copilot-linux-x64": "1.0.2", + "@github/copilot-win32-arm64": "1.0.2", + "@github/copilot-win32-x64": "1.0.2" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.68", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.68.tgz", - "integrity": "sha512-0G26AL9dlrwTa5IRTxPEnkX6Kz20CuetIwXzABmWwiXYcsR9rswM/NYICR3k53TOa0zL7aqFPnl98CW7J5XbZw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-dYoeaTidsphRXyMjvAgpjEbBV41ipICnXURrLFEiATcjC4IY6x2BqPOocrExBYW/Tz2VZvDw51iIZaf6GXrTmw==", "cpu": [ "arm64" ], @@ -406,9 +797,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.68", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.68.tgz", - "integrity": "sha512-RoClWH4CPH19pv5jrR0E6pBU6ljgrzL7idb7tV2pPtMYGTuwqW//XrIEE4n/5NVZmhzEiVBeq04THzOBeV493A==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.2.tgz", + "integrity": "sha512-8+Z9dYigEfXf0wHl9c2tgFn8Cr6v4RAY8xTgHMI9mZInjQyxVeBXCxbE2VgzUtDUD3a705Ka2d8ZOz05aYtGsg==", "cpu": [ "x64" ], @@ -422,9 +813,9 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.68", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.68.tgz", - "integrity": "sha512-SXSOz/2xPeUM/ndKypBiQv+QGYaEM7oGytl1i+Yx4tJnOoIwLkkTmIaWUbBNn0n5DTEjUcWyDqHzxxo/42FKRQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.2.tgz", + "integrity": "sha512-ik0Y5aTXOFRPLFrNjZJdtfzkozYqYeJjVXGBAH3Pp1nFZRu/pxJnrnQ1HrqO/LEgQVbJzAjQmWEfMbXdQIxE4Q==", "cpu": [ "arm64" ], @@ -438,9 +829,9 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.68", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.68.tgz", - "integrity": "sha512-YdG1chniWyps7XEJ2YHUOJkcOc6BpDQZby/zOKCVdswzRXx7d3WiZ2P9lfDimBBmXXJEJ81Fqhv2ZK5eOmGlUw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.2.tgz", + "integrity": "sha512-mHSPZjH4nU9rwbfwLxYJ7CQ90jK/Qu1v2CmvBCUPfmuGdVwrpGPHB5FrB+f+b0NEXjmemDWstk2zG53F7ppHfw==", "cpu": [ "x64" ], @@ -453,56 +844,24 @@ "copilot-linux-x64": "copilot" } }, - "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.68", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.68.tgz", - "integrity": "sha512-LTYZFOHpeLg4rCtsq3A/LMZxxRKFcCLmhnt8F7ovNYLNDJMkh3xdYanoXP0C0PO3uyUMiNJ+p5YXhZiBuo2yfw==", - "cpu": [ - "arm64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linuxmusl-arm64": "copilot" - } - }, - "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.68", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.68.tgz", - "integrity": "sha512-oOMXZ9HPJAJaKSrXZIYuond4uOiUkK1uRhVPI3Cs74n7uEVKPWpNlTN+j/O754dnBa1+HJcuZSDMulTbnOgirg==", - "cpu": [ - "x64" - ], - "license": "SEE LICENSE IN LICENSE.md", - "optional": true, - "os": [ - "linux" - ], - "bin": { - "copilot-linuxmusl-x64": "copilot" - } - }, "node_modules/@github/copilot-sdk": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.5.tgz", - "integrity": "sha512-N6Yk2DcpM9orYXWGBcQs5R0FdiVYrCn7UHQ206cUkfJengKYjgcd3f78BvVB6Dot3j0TvO04FnQ85K9/kbRRag==", + "version": "0.1.32", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-0.1.32.tgz", + "integrity": "sha512-mPWM0fw1Gqc/SW8nl45K8abrFH+92fO7y6tRtRl5imjS5hGapLf/dkX5WDrgPtlsflD0c41lFXVUri5NVJwtoA==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.67", + "@github/copilot": "^1.0.2", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=20.0.0" } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.68", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.68.tgz", - "integrity": "sha512-ZDqpJMP9Y5vqwvRxnIvZrVl8ibx/P66m3JTXQuzv6pitq7rkMEuNKscZ7cjJYN4N+BCOF5++5LKw8O1WHzXAAA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.2.tgz", + "integrity": "sha512-tLW2CY/vg0fYLp8EuiFhWIHBVzbFCDDpohxT/F/XyMAdTVSZLnopCcxQHv2BOu0CVGrYjlf7YOIwPfAKYml1FA==", "cpu": [ "arm64" ], @@ -516,9 +875,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.68", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.68.tgz", - "integrity": "sha512-eplj/Y2B+amMLJ37oNE6G8gx85j8ucAuJz+CjzpzprNiBUq45lFL8ukGeDtaLMRvIeYAEDYdz5yUzu2XtCE7mA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.2.tgz", + "integrity": "sha512-cFlc3xMkKKFRIYR00EEJ2XlYAemeh5EZHsGA8Ir2G0AH+DOevJbomdP1yyCC5gaK/7IyPkHX3sGie5sER2yPvQ==", "cpu": [ "x64" ], @@ -542,9 +901,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -555,19 +914,19 @@ "darwin" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -578,39 +937,19 @@ "darwin" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -625,9 +964,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -642,9 +981,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], @@ -659,9 +998,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -676,9 +1015,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "cpu": [ "ppc64" ], @@ -693,9 +1032,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", "cpu": [ "riscv64" ], @@ -710,9 +1049,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], @@ -727,9 +1066,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], @@ -744,9 +1083,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -761,9 +1100,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -778,9 +1117,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], @@ -791,19 +1130,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], @@ -814,19 +1153,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ "ppc64" ], @@ -837,19 +1176,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.2.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "cpu": [ "riscv64" ], @@ -860,19 +1199,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], @@ -883,19 +1222,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], @@ -906,19 +1245,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -929,19 +1268,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -952,56 +1291,39 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.1" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], "dev": true, - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@emnapi/runtime": "^1.7.0" }, "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" ], @@ -1012,16 +1334,16 @@ "win32" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], @@ -1032,16 +1354,16 @@ "win32" ], "engines": { - "node": "^20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], @@ -1052,7 +1374,7 @@ "win32" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1104,40 +1426,38 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", - "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.3" + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" } }, "node_modules/@oxc-project/types": { - "version": "0.138.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", - "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", + "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, "node_modules/@playwright/test": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", - "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.1" + "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -1153,9 +1473,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", - "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", "cpu": [ "arm64" ], @@ -1169,9 +1489,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", - "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", "cpu": [ "arm64" ], @@ -1185,9 +1505,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", - "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", "cpu": [ "x64" ], @@ -1201,9 +1521,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", - "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", "cpu": [ "x64" ], @@ -1217,9 +1537,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", - "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", + "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", "cpu": [ "arm" ], @@ -1233,9 +1553,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", - "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", "cpu": [ "arm64" ], @@ -1249,9 +1569,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", - "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", "cpu": [ "arm64" ], @@ -1265,9 +1585,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", - "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", "cpu": [ "ppc64" ], @@ -1281,9 +1601,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", - "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", "cpu": [ "s390x" ], @@ -1297,9 +1617,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", - "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", "cpu": [ "x64" ], @@ -1313,9 +1633,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", - "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", "cpu": [ "x64" ], @@ -1329,9 +1649,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", - "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", "cpu": [ "arm64" ], @@ -1345,37 +1665,25 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", - "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", + "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", "cpu": [ "wasm32" ], "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" + "@napi-rs/wasm-runtime": "^1.1.1" }, "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "node": ">=14.0.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", - "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", "cpu": [ "arm64" ], @@ -1389,9 +1697,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", - "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", "cpu": [ "x64" ], @@ -1405,9 +1713,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", + "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", "license": "MIT" }, "node_modules/@rollup/plugin-commonjs": { @@ -1480,27 +1788,6 @@ } } }, - "node_modules/@rollup/plugin-replace": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", - "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", - "license": "MIT", - "dependencies": { - "@rollup/pluginutils": "^5.0.1", - "magic-string": "^0.30.3" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" - }, - "peerDependenciesMeta": { - "rollup": { - "optional": true - } - } - }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -1855,24 +2142,23 @@ "license": "MIT" }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", - "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", + "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" } }, "node_modules/@sveltejs/adapter-node": { - "version": "5.5.7", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz", - "integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==", + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.4.tgz", + "integrity": "sha512-45X92CXW+2J8ZUzPv3eLlKWEzINKiiGeFWTjyER4ZN4sGgNoaoeSkCY/QYNxHpPXy71QPsctwccBo9jJs0ySPQ==", "license": "MIT", "dependencies": { "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", - "@rollup/plugin-replace": "^6.0.3", "rollup": "^4.59.0" }, "peerDependencies": { @@ -1880,17 +2166,17 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.69.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz", - "integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==", + "version": "2.55.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.55.0.tgz", + "integrity": "sha512-MdFRjevVxmAknf2NbaUkDF16jSIzXMWd4Nfah0Qp8TtQVoSp3bV4jKt8mX7z7qTUTWvgSaxtR0EG5WJf53gcuA==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", - "@sveltejs/acorn-typescript": "^1.0.9", + "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", - "acorn": "^8.16.0", + "acorn": "^8.14.1", "cookie": "^0.6.0", - "devalue": "^5.8.1", + "devalue": "^5.6.4", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", @@ -1908,7 +2194,7 @@ "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.3.3 || ^6.0.0", + "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -1920,20 +2206,10 @@ } } }, - "node_modules/@sveltejs/load-config": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz", - "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 18.0.0" - } - }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", - "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.0.0.tgz", + "integrity": "sha512-ILXmxC7HAsnkK2eslgPetrqqW1BKSL7LktsFgqzNj83MaivMGZzluWq32m25j2mDOjmSKX7GGWahePhuEs7P/g==", "license": "MIT", "dependencies": { "deepmerge": "^4.3.1", @@ -1949,6 +2225,43 @@ "vite": "^8.0.0-beta.7 || ^8.0.0" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/@testing-library/jest-dom": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", @@ -1969,16 +2282,63 @@ "yarn": ">=1" } }, + "node_modules/@testing-library/svelte": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.0.0" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -2022,6 +2382,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz", + "integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==", + "deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.", + "dev": true, + "license": "MIT", + "dependencies": { + "dompurify": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2053,11 +2424,10 @@ } }, "node_modules/@types/express-session": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.19.0.tgz", - "integrity": "sha512-GbypG0bog68UbOq2tSAp7SclvCUm3ha1uDi58OPRGK1NfRvCIu7Gz0M7fTGtpNG1T9a29GpuurQj9zEcT/lMXQ==", + "version": "1.18.2", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.18.2.tgz", + "integrity": "sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==", "dev": true, - "license": "MIT", "dependencies": { "@types/express": "*" } @@ -2075,13 +2445,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "25.9.4", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.4.tgz", - "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==", + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": ">=7.24.0 <7.24.7" + "undici-types": "~7.18.0" } }, "node_modules/@types/qs": { @@ -2169,14 +2539,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", - "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", + "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.0", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2184,14 +2554,14 @@ "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.10", - "vitest": "4.1.10" + "@vitest/browser": "4.1.0", + "vitest": "4.1.0" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2200,31 +2570,31 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.10", + "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2233,7 +2603,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -2255,26 +2625,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.1.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.10", + "@vitest/utils": "4.1.0", "pathe": "^2.0.3" }, "funding": { @@ -2282,14 +2652,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2298,9 +2668,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", "dev": true, "license": "MIT", "funding": { @@ -2308,15 +2678,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.10", + "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" + "tinyrainbow": "^3.0.3" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2360,26 +2730,23 @@ } }, "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" + "node": ">=8" } }, "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -2464,7 +2831,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -2550,6 +2916,23 @@ "node": ">=6" } }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -2580,7 +2963,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, "license": "MIT", "dependencies": { "mdn-data": "2.27.1", @@ -2601,7 +2983,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", @@ -2623,7 +3004,6 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, "license": "MIT" }, "node_modules/deepmerge": { @@ -2643,6 +3023,16 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2653,9 +3043,9 @@ } }, "node_modules/devalue": { - "version": "5.8.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", - "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", + "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", "license": "MIT" }, "node_modules/dom-accessibility-api": { @@ -2666,27 +3056,14 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, - "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -2704,13 +3081,12 @@ "license": "MIT" }, "node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "license": "BSD-2-Clause", "engines": { - "node": ">=20.19.0" + "node": ">=0.12" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -2736,6 +3112,47 @@ "dev": true, "license": "MIT" }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "devOptional": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -2743,20 +3160,12 @@ "license": "MIT" }, "node_modules/esrap": { - "version": "2.2.13", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.13.tgz", - "integrity": "sha512-m8jH5hZgJE2RRUK/jjkGPcJEDAV+dYnZYFkosQaPTcE+Yw4xynXHOo6FUdwaWBtdR3b1MMa7wEDTSHeR2VWsGA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", + "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "peerDependencies": { - "@typescript-eslint/types": "^8.2.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/types": { - "optional": true - } } }, "node_modules/estree-walker": { @@ -2857,9 +3266,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "dev": true, "license": "MIT", "engines": { @@ -2869,6 +3278,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "devOptional": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2909,7 +3330,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.6.0" @@ -3051,7 +3471,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, "license": "MIT" }, "node_modules/is-reference": { @@ -3069,6 +3488,19 @@ "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", "license": "MIT" }, + "node_modules/isomorphic-dompurify": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-3.6.0.tgz", + "integrity": "sha512-0dF93ZF/7SgzdnCjXTsqSteJmhlR5PNhq/mJXRMQtnFdaGdG94ul6mYF80hSKfh6VfmLsaGKBd0Eye4eUHyeWA==", + "license": "MIT", + "dependencies": { + "dompurify": "^3.3.3", + "jsdom": "^29.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -3108,29 +3540,35 @@ "node": ">=8" } }, - "node_modules/jsdom": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", "license": "MIT", "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", - "parse5": "^8.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", - "undici": "^7.25.0", + "undici": "^7.24.5", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", @@ -3450,45 +3888,45 @@ } }, "node_modules/lint-staged": { - "version": "17.0.8", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", - "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", "dev": true, "license": "MIT", "dependencies": { - "listr2": "^10.2.1", - "picomatch": "^4.0.4", + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", "string-argv": "^0.3.2", - "tinyexec": "^1.2.4" + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" }, "bin": { "lint-staged": "bin/lint-staged.js" }, "engines": { - "node": ">=22.22.1" + "node": ">=20.17" }, "funding": { "url": "https://opencollective.com/lint-staged" - }, - "optionalDependencies": { - "yaml": "^2.9.0" } }, "node_modules/listr2": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", - "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^5.2.0", - "eventemitter3": "^5.0.4", + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", - "wrap-ansi": "^10.0.0" + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=22.13.0" + "node": ">=20.0.0" } }, "node_modules/locate-character": { @@ -3517,76 +3955,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", "dev": true, "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, "engines": { - "node": ">=18" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "is-fullwidth-code-point": "^5.0.0" }, "engines": { "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, "node_modules/lru-cache": { - "version": "11.3.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", - "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", - "dev": true, + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", "license": "BlueOak-1.0.0", "engines": { "node": "20 || >=22" } }, - "node_modules/lucide-svelte": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-1.0.1.tgz", - "integrity": "sha512-WvzZgk0pqzgda+AErLvgWxHkfg/+GgUwqKMRHvzt0IqyMdmyEDzDCk3Z+Wo/3y753oIgx8u9Q4eUbWkghFa8Jg==", - "license": "ISC", - "peerDependencies": { - "svelte": "^3 || ^4 || ^5.0.0-next.42" + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" } }, "node_modules/magic-string": { @@ -3627,9 +4042,9 @@ } }, "node_modules/marked": { - "version": "18.0.5", - "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", - "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz", + "integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==", "license": "MIT", "bin": { "marked": "bin/marked.js" @@ -3642,7 +4057,6 @@ "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, "license": "CC0-1.0" }, "node_modules/mimic-function": { @@ -3708,9 +4122,9 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, "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==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "funding": [ { "type": "github", @@ -3769,13 +4183,12 @@ } }, "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", "license": "MIT", "dependencies": { - "entities": "^8.0.0" + "entities": "^6.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -3809,9 +4222,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { "node": ">=12" @@ -3821,13 +4234,13 @@ } }, "node_modules/playwright": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", - "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.1" + "playwright-core": "1.58.2" }, "bin": { "playwright": "cli.js" @@ -3840,9 +4253,9 @@ } }, "node_modules/playwright-core": { - "version": "1.61.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", - "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3868,9 +4281,9 @@ } }, "node_modules/postcss": { - "version": "8.5.16", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", - "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "funding": [ { "type": "opencollective", @@ -3887,7 +4300,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3895,11 +4308,25 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3913,6 +4340,13 @@ "node": ">= 0.8" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", @@ -3945,7 +4379,6 @@ "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==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3971,6 +4404,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -4018,13 +4460,13 @@ "license": "MIT" }, "node_modules/rolldown": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", - "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", + "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.138.0", - "@rolldown/pluginutils": "^1.0.0" + "@oxc-project/types": "=0.120.0", + "@rolldown/pluginutils": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" @@ -4033,21 +4475,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.4", - "@rolldown/binding-darwin-arm64": "1.1.4", - "@rolldown/binding-darwin-x64": "1.1.4", - "@rolldown/binding-freebsd-x64": "1.1.4", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", - "@rolldown/binding-linux-arm64-gnu": "1.1.4", - "@rolldown/binding-linux-arm64-musl": "1.1.4", - "@rolldown/binding-linux-ppc64-gnu": "1.1.4", - "@rolldown/binding-linux-s390x-gnu": "1.1.4", - "@rolldown/binding-linux-x64-gnu": "1.1.4", - "@rolldown/binding-linux-x64-musl": "1.1.4", - "@rolldown/binding-openharmony-arm64": "1.1.4", - "@rolldown/binding-wasm32-wasi": "1.1.4", - "@rolldown/binding-win32-arm64-msvc": "1.1.4", - "@rolldown/binding-win32-x64-msvc": "1.1.4" + "@rolldown/binding-android-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-x64": "1.0.0-rc.10", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" } }, "node_modules/rollup": { @@ -4135,7 +4577,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -4145,9 +4586,9 @@ } }, "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -4181,53 +4622,48 @@ "license": "MIT" }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "dev": true, + "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "@img/colour": "^1.1.0", + "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", - "semver": "^7.8.5" + "semver": "^7.7.3" }, "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/siginfo": { @@ -4274,6 +4710,19 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4308,9 +4757,9 @@ } }, "node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", "dev": true, "license": "MIT", "dependencies": { @@ -4340,6 +4789,19 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -4379,23 +4841,23 @@ } }, "node_modules/svelte": { - "version": "5.56.4", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", - "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", + "version": "5.54.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.54.1.tgz", + "integrity": "sha512-ow8tncN097Ty8U1H+C3bM1xNlsCbnO2UZeN0lWBnv8f3jKho7QTTQ2LWbMXrPQDodLjH91n4kpNnLolyRhVE6A==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.10", + "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.8.1", + "devalue": "^5.6.4", "esm-env": "^1.2.1", - "esrap": "^2.2.12", + "esrap": "^2.2.2", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -4406,14 +4868,13 @@ } }, "node_modules/svelte-check": { - "version": "4.7.2", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.2.tgz", - "integrity": "sha512-GoS4XJdGswlq0rIT1vtFLzJY1bvHtY37McY9H9Gkm1Ggw/ICdZYn8J/Z8Yi0BEL0i3R4+jtaWVePjyppMlij/A==", + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.4.5.tgz", + "integrity": "sha512-1bSwIRCvvmSHrlK52fOlZmVtUZgil43jNL/2H18pRpa+eQjzGt6e3zayxhp1S7GajPFKNM/2PMCG+DZFHlG9fw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", - "@sveltejs/load-config": "^0.2.0", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", @@ -4443,7 +4904,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, "license": "MIT" }, "node_modules/tinybench": { @@ -4454,9 +4914,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", "dev": true, "license": "MIT", "engines": { @@ -4464,13 +4924,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.4" + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -4493,7 +4953,6 @@ "version": "7.0.27", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", - "dev": true, "license": "MIT", "dependencies": { "tldts-core": "^7.0.27" @@ -4506,7 +4965,6 @@ "version": "7.0.27", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", - "dev": true, "license": "MIT" }, "node_modules/totalist": { @@ -4522,7 +4980,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -4535,7 +4992,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -4551,6 +5007,25 @@ "license": "0BSD", "optional": true }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/typedarray-to-buffer": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", @@ -4561,11 +5036,10 @@ } }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, - "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4586,19 +5060,18 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "dev": true, + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz", + "integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==", "license": "MIT", "engines": { "node": ">=20.18.1" } }, "node_modules/undici-types": { - "version": "7.24.6", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", - "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "devOptional": true, "license": "MIT" }, @@ -4612,16 +5085,16 @@ } }, "node_modules/vite": { - "version": "8.1.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", - "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", + "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.16", - "rolldown": "~1.1.3", - "tinyglobby": "^0.2.17" + "picomatch": "^4.0.3", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.10", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" @@ -4637,8 +5110,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -4708,19 +5181,19 @@ } }, "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4731,8 +5204,8 @@ "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -4748,15 +5221,13 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -4777,12 +5248,6 @@ "@vitest/browser-webdriverio": { "optional": true }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, "@vitest/ui": { "optional": true }, @@ -4809,7 +5274,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -4841,7 +5305,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" @@ -4851,7 +5314,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -4861,7 +5323,6 @@ "version": "16.0.1", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", @@ -4890,23 +5351,54 @@ } }, "node_modules/wrap-ansi": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", - "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.3", - "string-width": "^8.2.0", - "strip-ansi": "^7.1.2" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=20" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/write-file-atomic": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", @@ -4920,10 +5412,9 @@ } }, "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", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "engines": { "node": ">=10.0.0" }, @@ -4944,7 +5435,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -4954,15 +5444,14 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, "license": "MIT" }, "node_modules/yaml": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", - "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "devOptional": true, "license": "ISC", - "optional": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 309548d..f5726c4 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,15 @@ { "name": "copilot-unleashed", - "version": "2.2.0", + "version": "4.0.0", "description": "Copilot Unleashed — Self-hosted multi-model AI chat powered by the GitHub Copilot SDK", "main": "build/index.js", "scripts": { "build:server": "tsc -p tsconfig.node.json", "build": "npm run build:server && vite build", "start": "node server.js", - "dev": "docker compose up --build", - "dev:down": "docker compose down", - "build:check": "npm run build && node --env-file=.env server.js", + "dev": "docker-compose up --build", + "dev:down": "docker-compose down", + "preview": "npm run build && node --env-file=.env server.js", "bundle-sessions": "node scripts/bundle-sessions.mjs", "sync:push": "node scripts/sync-push.mjs", "check": "svelte-check --tsconfig ./tsconfig.json", @@ -20,7 +20,7 @@ "test:unit:coverage": "vitest run --coverage", "clean": "rm -rf build dist .svelte-kit", "postinstall": "node scripts/patch-sdk.mjs", - "prepare": "git rev-parse --git-dir > /dev/null 2>&1 && husky || true" + "prepare": "husky" }, "keywords": [ "copilot", @@ -38,19 +38,19 @@ "node": ">=24.0.0" }, "dependencies": { - "@github/copilot-sdk": "^1.0.5", - "@sveltejs/adapter-node": "^5.5.7", - "@sveltejs/kit": "^2.68.0", - "dompurify": "^3.4.11", + "@github/copilot-sdk": "^0.1.32", + "@sveltejs/adapter-node": "^5.5.4", + "@sveltejs/kit": "^2.55.0", + "dompurify": "^3.3.3", "express-session": "^1.18.0", "highlight.js": "^11.11.1", - "lucide-svelte": "^1.0.1", - "marked": "^18.0.5", + "isomorphic-dompurify": "^3.6.0", + "marked": "^17.0.4", "session-file-store": "^1.5.0", - "svelte": "^5.56.4", - "vite": "^8.1.3", + "svelte": "^5.54.1", + "vite": "^8.0.1", "web-push": "^3.6.7", - "ws": "^8.21.0" + "ws": "^8.18.0" }, "lint-staged": { "*.{ts,svelte}": [ @@ -61,22 +61,24 @@ "cookie": "^0.7.0" }, "devDependencies": { - "@playwright/test": "^1.61.0", - "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@playwright/test": "^1.58.2", + "@sveltejs/vite-plugin-svelte": "^7.0.0", "@testing-library/jest-dom": "^6.9.1", - "@types/express-session": "^1.19.0", - "@types/node": "^25.9.3", + "@testing-library/svelte": "^5.3.1", + "@types/dompurify": "^3.2.0", + "@types/express-session": "^1.18.0", + "@types/node": "^25.5.0", "@types/session-file-store": "^1.2.6", "@types/web-push": "^3.6.4", "@types/ws": "^8.5.13", - "@vitest/coverage-v8": "^4.1.9", - "dotenv": "^17.4.2", + "@vitest/coverage-v8": "^4.1.0", "husky": "^9.1.7", - "jsdom": "^29.1.1", - "lint-staged": "^17.0.7", - "sharp": "^0.35.1", - "svelte-check": "^4.6.0", - "typescript": "^6.0.3", - "vitest": "^4.1.3" + "jsdom": "^29.0.1", + "lint-staged": "^16.4.0", + "sharp": "^0.34.5", + "svelte-check": "^4.4.5", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^4.1.0" } } diff --git a/playwright.config.ts b/playwright.config.ts index 814b3fc..4f47378 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -34,7 +34,6 @@ export default defineConfig({ GITHUB_CLIENT_ID: 'test-client-id', SESSION_SECRET: 'test-secret-for-playwright', NODE_ENV: 'development', - E2E_DISABLE_RATE_LIMIT: 'true', }, }, }); diff --git a/infra/scripts/set-deployer-ip.sh b/scripts/set-deployer-ip.sh similarity index 100% rename from infra/scripts/set-deployer-ip.sh rename to scripts/set-deployer-ip.sh diff --git a/infra/scripts/setup-branch-protection.sh b/scripts/setup-branch-protection.sh similarity index 100% rename from infra/scripts/setup-branch-protection.sh rename to scripts/setup-branch-protection.sh diff --git a/infra/scripts/setup-security.sh b/scripts/setup-security.sh similarity index 100% rename from infra/scripts/setup-security.sh rename to scripts/setup-security.sh diff --git a/server.js b/server.js index 5384c2e..312e92b 100644 --- a/server.js +++ b/server.js @@ -3,30 +3,26 @@ import session from 'express-session'; import FileStoreFactory from 'session-file-store'; import { setupWebSocket } from './dist/ws/handler.js'; import { registerSession, deleteSessionById } from './dist/session-store.js'; -import { config } from './dist/config.js'; - -// Load .env file for local development (dotenv is a devDependency) -try { const { config: dotenvConfig } = await import('dotenv'); dotenvConfig(); } catch {}; +import { unsealAuth, parseCookieValue, AUTH_COOKIE_NAME } from './dist/auth/auth-cookie.js'; const FileStore = FileStoreFactory(session); -const isDev = config.isDev; -const port = config.port; -// config.sessionSecret throws fail-fast if SESSION_SECRET is unset — no fallback -const sessionSecret = config.sessionSecret; -const tokenMaxAge = config.tokenMaxAge; +const isDev = process.env.NODE_ENV !== 'production'; +const port = parseInt(process.env.PORT || '3000'); +const sessionSecret = process.env.SESSION_SECRET || 'dev-secret-change-me'; +const tokenMaxAge = parseInt(process.env.TOKEN_MAX_AGE_MS || String(7 * 24 * 60 * 60 * 1000)); // Set ORIGIN for SvelteKit adapter-node CSRF check before importing handler. // Without this, adapter-node defaults protocol to 'https', causing origin mismatch on plain HTTP. if (!process.env.ORIGIN) { - process.env.ORIGIN = config.baseUrl; -} -// Raise adapter-node body limit to match upload endpoint's 50MB cap (default is 512KB) -if (!process.env.BODY_SIZE_LIMIT) { - process.env.BODY_SIZE_LIMIT = String(50 * 1024 * 1024); + process.env.ORIGIN = process.env.BASE_URL || `http://localhost:${port}`; } const { handler } = await import('./build/handler.js'); -const sessionStorePath = config.sessionStorePath; +if (!isDev && !process.env.SESSION_SECRET) { + throw new Error('SESSION_SECRET environment variable is required in production'); +} + +const sessionStorePath = process.env.SESSION_STORE_PATH || (isDev ? '.sessions' : '/data/sessions'); const sessionMiddleware = session({ store: new FileStore({ path: sessionStorePath, ttl: 86400, retries: 0, logFn: () => {} }), secret: sessionSecret, @@ -42,11 +38,26 @@ const sessionMiddleware = session({ }, }); -// Auth-cookie restore is handled canonically in src/hooks.server.ts (sessionHandle). -// Keeping a single restore path avoids drift and duplicated logic. +/** Restore auth from encrypted cookie when session file is missing (e.g. after EmptyDir wipe). */ +function restoreAuthFromCookie(req) { + if (req.session && !req.session.githubToken) { + const sealed = parseCookieValue(req.headers.cookie, AUTH_COOKIE_NAME); + if (sealed) { + const data = unsealAuth(sealed, sessionSecret, tokenMaxAge); + if (data) { + req.session.githubToken = data.githubToken; + req.session.githubUser = data.githubUser; + req.session.githubAuthTime = data.githubAuthTime; + req.session.save(() => {}); + console.log(`[AUTH-COOKIE] Restored auth for user=${data.githubUser.login}`); + } + } + } +} const server = createServer((req, res) => { sessionMiddleware(req, res, () => { + restoreAuthFromCookie(req); const sessionId = registerSession(req.session); req.headers['x-session-id'] = sessionId; @@ -56,24 +67,6 @@ const server = createServer((req, res) => { return origEnd(...args); }; - const origSetHeader = res.setHeader.bind(res); - res.setHeader = function (...args) { - if (!res.headersSent) return origSetHeader(...args); - if (process.env.NODE_ENV !== 'production') { - console.warn(`[WARN] setHeader("${args[0]}") called after headers sent — ${req.method} ${req.url}`); - } - return res; - }; - - const origWriteHead = res.writeHead.bind(res); - res.writeHead = function (...args) { - if (!res.headersSent) return origWriteHead(...args); - if (process.env.NODE_ENV !== 'production') { - console.warn(`[WARN] writeHead(${args[0]}) called after headers sent — ${req.method} ${req.url}`); - } - return res; - }; - handler(req, res); }); }); diff --git a/skills/automate-this/SKILL.md b/skills/automate-this/SKILL.md new file mode 100644 index 0000000..3d0cac5 --- /dev/null +++ b/skills/automate-this/SKILL.md @@ -0,0 +1,244 @@ +--- +name: automate-this +description: 'Analyze a screen recording of a manual process and produce targeted, working automation scripts. Extracts frames and audio narration from video files, reconstructs the step-by-step workflow, and proposes automation at multiple complexity levels using tools already installed on the user machine.' +--- + +# Automate This + +Analyze a screen recording of a manual process and build working automation for it. + +The user records themselves doing something repetitive or tedious, hands you the video file, and you figure out what they're doing, why, and how to script it away. + +## Prerequisites Check + +Before analyzing any recording, verify the required tools are available. Run these checks silently and only surface problems: + +```bash +command -v ffmpeg >/dev/null 2>&1 && ffmpeg -version 2>/dev/null | head -1 || echo "NO_FFMPEG" +command -v whisper >/dev/null 2>&1 || command -v whisper-cpp >/dev/null 2>&1 || echo "NO_WHISPER" +``` + +- **ffmpeg is required.** If missing, tell the user: `brew install ffmpeg` (macOS) or the equivalent for their OS. +- **Whisper is optional.** Only needed if the recording has narration. If missing AND the recording has an audio track, suggest: `pip install openai-whisper` or `brew install whisper-cpp`. If the user declines, proceed with visual analysis only. + +## Phase 1: Extract Content from the Recording + +Given a video file path (typically on `~/Desktop/`), extract both visual frames and audio: + +### Frame Extraction + +Extract frames at one frame every 2 seconds. This balances coverage with context window limits. + +```bash +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/automate-this-XXXXXX") +chmod 700 "$WORK_DIR" +mkdir -p "$WORK_DIR/frames" +ffmpeg -y -i "" -vf "fps=0.5" -q:v 2 -loglevel warning "$WORK_DIR/frames/frame_%04d.jpg" +ls "$WORK_DIR/frames/" | wc -l +``` + +Use `$WORK_DIR` for all subsequent temp file paths in the session. The per-run directory with mode 0700 ensures extracted frames are only readable by the current user. + +If the recording is longer than 5 minutes (more than 150 frames), increase the interval to one frame every 4 seconds to stay within context limits. Tell the user you're sampling less frequently for longer recordings. + +### Audio Extraction and Transcription + +Check if the video has an audio track: + +```bash +ffprobe -i "" -show_streams -select_streams a -loglevel error | head -5 +``` + +If audio exists: + +```bash +ffmpeg -y -i "" -ac 1 -ar 16000 -loglevel warning "$WORK_DIR/audio.wav" + +# Use whichever whisper binary is available +if command -v whisper >/dev/null 2>&1; then + whisper "$WORK_DIR/audio.wav" --model small --language en --output_format txt --output_dir "$WORK_DIR/" + cat "$WORK_DIR/audio.txt" +elif command -v whisper-cpp >/dev/null 2>&1; then + whisper-cpp -m "$(brew --prefix 2>/dev/null)/share/whisper-cpp/models/ggml-small.bin" -l en -f "$WORK_DIR/audio.wav" -otxt -of "$WORK_DIR/audio" + cat "$WORK_DIR/audio.txt" +else + echo "NO_WHISPER" +fi +``` + +If neither whisper binary is available and the recording has audio, inform the user they're missing narration context and ask if they want to install Whisper (`pip install openai-whisper` or `brew install whisper-cpp`) or proceed with visual-only analysis. + +## Phase 2: Reconstruct the Process + +Analyze the extracted frames (and transcript, if available) to build a structured understanding of what the user did. Work through the frames sequentially and identify: + +1. **Applications used** — Which apps appear in the recording? (browser, terminal, Finder, mail client, spreadsheet, IDE, etc.) +2. **Sequence of actions** — What did the user do, in order? Click-by-click, step-by-step. +3. **Data flow** — What information moved between steps? (copied text, downloaded files, form inputs, etc.) +4. **Decision points** — Were there moments where the user paused, checked something, or made a choice? +5. **Repetition patterns** — Did the user do the same thing multiple times with different inputs? +6. **Pain points** — Where did the process look slow, error-prone, or tedious? The narration often reveals this directly ("I hate this part," "this always takes forever," "I have to do this for every single one"). + +Present this reconstruction to the user as a numbered step list and ask them to confirm it's accurate before proposing automation. This is critical — a wrong understanding leads to useless automation. + +Format: + +``` +Here's what I see you doing in this recording: + +1. Open Chrome and navigate to [specific URL] +2. Log in with credentials +3. Click through to the reporting dashboard +4. Download a CSV export +5. Open the CSV in Excel +6. Filter rows where column B is "pending" +7. Copy those rows into a new spreadsheet +8. Email the new spreadsheet to [recipient] + +You repeated steps 3-8 three times for different report types. + +[If narration was present]: You mentioned that the export step is the slowest +part and that you do this every Monday morning. + +Does this match what you were doing? Anything I got wrong or missed? +``` + +Do NOT proceed to Phase 3 until the user confirms the reconstruction is accurate. + +## Phase 3: Environment Fingerprint + +Before proposing automation, understand what the user actually has to work with. Run these checks: + +```bash +echo "=== OS ===" && uname -a +echo "=== Shell ===" && echo $SHELL +echo "=== Python ===" && { command -v python3 && python3 --version 2>&1; } || echo "not installed" +echo "=== Node ===" && { command -v node && node --version 2>&1; } || echo "not installed" +echo "=== Homebrew ===" && { command -v brew && echo "installed"; } || echo "not installed" +echo "=== Common Tools ===" && for cmd in curl jq playwright selenium osascript automator crontab; do command -v $cmd >/dev/null 2>&1 && echo "$cmd: yes" || echo "$cmd: no"; done +``` + +Use this to constrain proposals to tools the user already has. Never propose automation that requires installing five new things unless the simpler path genuinely doesn't work. + +## Phase 4: Propose Automation + +Based on the reconstructed process and the user's environment, propose automation at up to three tiers. Not every process needs three tiers — use judgment. + +### Tier Structure + +**Tier 1 — Quick Win (under 5 minutes to set up)** +The smallest useful automation. A shell alias, a one-liner, a keyboard shortcut, an AppleScript snippet. Automates the single most painful step, not the whole process. + +**Tier 2 — Script (under 30 minutes to set up)** +A standalone script (bash, Python, or Node — whichever the user has) that automates the full process end-to-end. Handles common errors. Can be run manually when needed. + +**Tier 3 — Full Automation (under 2 hours to set up)** +The script from Tier 2, plus: scheduled execution (cron, launchd, or GitHub Actions), logging, error notifications, and any necessary integration scaffolding (API keys, auth tokens, etc.). + +### Proposal Format + +For each tier, provide: + +``` +## Tier [N]: [Name] + +**What it automates:** [Which steps from the reconstruction] +**What stays manual:** [Which steps still need a human] +**Time savings:** [Estimated time saved per run, based on the recording length and repetition count] +**Prerequisites:** [Anything needed that isn't already installed — ideally nothing] + +**How it works:** +[2-3 sentence plain-English explanation] + +**The code:** +[Complete, working, commented code — not pseudocode] + +**How to test it:** +[Exact steps to verify it works, starting with a dry run if possible] + +**How to undo:** +[How to reverse any changes if something goes wrong] +``` + +### Application-Specific Automation Strategies + +Use these strategies based on which applications appear in the recording: + +**Browser-based workflows:** +- First choice: Check if the website has a public API. API calls are 10x more reliable than browser automation. Search for API documentation. +- Second choice: `curl` or `wget` for simple HTTP requests with known endpoints. +- Third choice: Playwright or Selenium for workflows that require clicking through UI. Prefer Playwright — it's faster and less flaky. +- Look for patterns: if the user is downloading the same report from a dashboard repeatedly, it's almost certainly available via API or direct URL with query parameters. + +**Spreadsheet and data workflows:** +- Python with pandas for data filtering, transformation, and aggregation. +- If the user is doing simple column operations in Excel, a 5-line Python script replaces the entire manual process. +- `csvkit` for quick command-line CSV manipulation without writing code. +- If the output needs to stay in Excel format, use openpyxl. + +**Email workflows:** +- macOS: `osascript` can control Mail.app to send emails with attachments. +- Cross-platform: Python `smtplib` for sending, `imaplib` for reading. +- If the email follows a template, generate the body from a template file with variable substitution. + +**File management workflows:** +- Shell scripts for move/copy/rename patterns. +- `find` + `xargs` for batch operations. +- `fswatch` or `watchman` for triggered-on-change automation. +- If the user is organizing files into folders by date or type, that's a 3-line shell script. + +**Terminal/CLI workflows:** +- Shell aliases for frequently typed commands. +- Shell functions for multi-step sequences. +- Makefiles for project-specific task sets. +- If the user ran the same command with different arguments, that's a loop. + +**macOS-specific workflows:** +- AppleScript/JXA for controlling native apps (Mail, Calendar, Finder, Preview, etc.). +- Shortcuts.app for simple multi-app workflows that don't need code. +- `automator` for file-based workflows. +- `launchd` plist files for scheduled tasks (prefer over cron on macOS). + +**Cross-application workflows (data moves between apps):** +- Identify the data transfer points. Each transfer is an automation opportunity. +- Clipboard-based transfers in the recording suggest the apps don't talk to each other — look for APIs, file-based handoffs, or direct integrations instead. +- If the user copies from App A and pastes into App B, the automation should read from A's data source and write to B's input format directly. + +### Making Proposals Targeted + +Apply these principles to every proposal: + +1. **Automate the bottleneck first.** The narration and timing in the recording reveal which step is actually painful. A 30-second automation of the worst step beats a 2-hour automation of the whole process. + +2. **Match the user's skill level.** If the recording shows someone comfortable in a terminal, propose shell scripts. If it shows someone navigating GUIs, propose something with a simple trigger (double-click a script, run a Shortcut, or type one command). + +3. **Estimate real time savings.** Count the recording duration and multiply by how often they do it. "This recording is 4 minutes. You said you do this daily. That's 17 hours per year. Tier 1 cuts it to 30 seconds each time — you get 16 hours back." + +4. **Handle the 80% case.** The first version of the automation should cover the common path perfectly. Edge cases can be handled in Tier 3 or flagged for manual intervention. + +5. **Preserve human checkpoints.** If the recording shows the user reviewing or approving something mid-process, keep that as a manual step. Don't automate judgment calls. + +6. **Propose dry runs.** Every script should have a mode where it shows what it *would* do without doing it. `--dry-run` flags, preview output, or confirmation prompts before destructive actions. + +7. **Account for auth and secrets.** If the process involves logging in or using credentials, never hardcode them. Use environment variables, keychain access (macOS `security` command), or prompt for them at runtime. + +8. **Consider failure modes.** What happens if the website is down? If the file doesn't exist? If the format changes? Good proposals mention this and handle it. + +## Phase 5: Build and Test + +When the user picks a tier: + +1. Write the complete automation code to a file (suggest a sensible location — the user's project directory if one exists, or `~/Desktop/` otherwise). +2. Walk through a dry run or test with the user watching. +3. If the test works, show how to run it for real. +4. If it fails, diagnose and fix — don't give up after one attempt. + +## Cleanup + +After analysis is complete (regardless of outcome), clean up extracted frames and audio: + +```bash +rm -rf "$WORK_DIR" +``` + +Tell the user you're cleaning up temporary files so they know nothing is left behind. diff --git a/skills/copilot-spaces/SKILL.md b/skills/copilot-spaces/SKILL.md new file mode 100644 index 0000000..616952a --- /dev/null +++ b/skills/copilot-spaces/SKILL.md @@ -0,0 +1,205 @@ +--- +name: copilot-spaces +description: 'Use Copilot Spaces to provide project-specific context to conversations. Use this skill when users mention a "Copilot space", want to load context from a shared knowledge base, discover available spaces, or ask questions grounded in curated project documentation, code, and instructions.' +--- + +# Copilot Spaces + +Use Copilot Spaces to bring curated, project-specific context into conversations. A Space is a shared collection of repositories, files, documentation, and instructions that grounds Copilot responses in your team's actual code and knowledge. + +## Available Tools + +### MCP Tools (Read-only) + +| Tool | Purpose | +|------|---------| +| `mcp__github__list_copilot_spaces` | List all spaces accessible to the current user | +| `mcp__github__get_copilot_space` | Load a space's full context by owner and name | + +### REST API via `gh api` (Full CRUD) + +The Spaces REST API supports creating, updating, deleting spaces, and managing collaborators. The MCP server only exposes read operations, so use `gh api` for writes. + +**User Spaces:** + +| Method | Endpoint | Purpose | +|--------|----------|---------| +| `POST` | `/users/{username}/copilot-spaces` | Create a space | +| `GET` | `/users/{username}/copilot-spaces` | List spaces | +| `GET` | `/users/{username}/copilot-spaces/{number}` | Get a space | +| `PUT` | `/users/{username}/copilot-spaces/{number}` | Update a space | +| `DELETE` | `/users/{username}/copilot-spaces/{number}` | Delete a space | + +**Organization Spaces:** Same pattern under `/orgs/{org}/copilot-spaces/...` + +**Collaborators:** Add, list, update, and remove collaborators at `.../collaborators` + +**Scope requirements:** PAT needs `read:user` for reads, `user` for writes. Add with `gh auth refresh -h github.com -s user`. + +**Note:** This API is functional but not yet in the public REST API docs. It may require the `copilot_spaces_api` feature flag. + +## When to Use Spaces + +- User mentions "Copilot space" or asks to "load a space" +- User wants answers grounded in specific project docs, code, or standards +- User asks "what spaces are available?" or "find a space for X" +- User needs onboarding context, architecture docs, or team-specific guidance +- User wants to follow a structured workflow defined in a Space (templates, checklists, multi-step processes) + +## Workflow + +### 1. Discover Spaces + +When a user asks what spaces are available or you need to find the right space: + +``` +Call mcp__github__list_copilot_spaces +``` + +This returns all spaces the user can access, each with a `name` and `owner_login`. Present relevant matches to the user. + +To filter for a specific user's spaces, match `owner_login` against the username (e.g., "show me my spaces"). + +### 2. Load a Space + +When a user names a specific space or you've identified the right one: + +``` +Call mcp__github__get_copilot_space with: + owner: "org-or-user" (the owner_login from the list) + name: "Space Name" (exact space name, case-sensitive) +``` + +This returns the space's full content: attached documentation, code context, custom instructions, and any other curated materials. Use this context to inform your responses. + +### 3. Follow the Breadcrumbs + +Space content often references external resources: GitHub issues, dashboards, repos, discussions, or other tools. Proactively fetch these using other MCP tools to gather complete context. For example: +- A space references an initiative tracking issue. Use `issue_read` to get the latest comments. +- A space links to a project board. Use project tools to check current status. +- A space mentions a repo's masterplan. Use `get_file_contents` to read it. + +### 4. Answer or Execute + +Once loaded, use the space content based on what it contains: + +**If the space contains reference material** (docs, code, standards): +- Answer questions about the project's architecture, patterns, or standards +- Generate code that follows the team's conventions +- Debug issues using project-specific knowledge + +**If the space contains workflow instructions** (templates, step-by-step processes): +- Follow the workflow as defined, step by step +- Gather data from the sources the workflow specifies +- Produce output in the format the workflow defines +- Show progress after each step so the user can steer + +### 5. Manage Spaces (via `gh api`) + +When a user wants to create, update, or delete a space, use `gh api`. First, find the space number from the list endpoint. + +**Update a space's instructions:** +```bash +gh api users/{username}/copilot-spaces/{number} \ + -X PUT \ + -f general_instructions="New instructions here" +``` + +**Update name, description, or instructions together:** +```bash +gh api users/{username}/copilot-spaces/{number} \ + -X PUT \ + -f name="Updated Name" \ + -f description="Updated description" \ + -f general_instructions="Updated instructions" +``` + +**Create a new space:** +```bash +gh api users/{username}/copilot-spaces \ + -X POST \ + -f name="My New Space" \ + -f general_instructions="Help me with..." \ + -f visibility="private" +``` + +**Attach resources (replaces entire resource list):** +```json +{ + "resources_attributes": [ + { "resource_type": "free_text", "metadata": { "name": "Notes", "text": "Content here" } }, + { "resource_type": "github_issue", "metadata": { "repository_id": 12345, "number": 42 } }, + { "resource_type": "github_file", "metadata": { "repository_id": 12345, "file_path": "docs/guide.md" } } + ] +} +``` + +**Delete a space:** +```bash +gh api users/{username}/copilot-spaces/{number} -X DELETE +``` + +**Updatable fields:** `name`, `description`, `general_instructions`, `icon_type`, `icon_color`, `visibility` ("private"/"public"), `base_role` ("no_access"/"reader"), `resources_attributes` + +## Examples + +### Example 1: User Asks for a Space + +**User**: "Load the Accessibility copilot space" + +**Action**: +1. Call `mcp__github__get_copilot_space` with owner `"github"`, name `"Accessibility"` +2. Use the returned context to answer questions about accessibility standards, MAS grades, compliance processes, etc. + +### Example 2: User Wants to Find Spaces + +**User**: "What copilot spaces are available for our team?" + +**Action**: +1. Call `mcp__github__list_copilot_spaces` +2. Filter/present spaces relevant to the user's org or interests +3. Offer to load any space they're interested in + +### Example 3: Context-Grounded Question + +**User**: "Using the security space, what's our policy on secret scanning?" + +**Action**: +1. Call `mcp__github__get_copilot_space` with the appropriate owner and name +2. Find the relevant policy in the space content +3. Answer based on the actual internal documentation + +### Example 4: Space as a Workflow Engine + +**User**: "Write my weekly update using the PM Weekly Updates space" + +**Action**: +1. Call `mcp__github__get_copilot_space` to load the space. It contains a template format and step-by-step instructions. +2. Follow the space's workflow: pull data from attached initiative issues, gather metrics, draft each section. +3. Fetch external resources referenced by the space (tracking issues, dashboards) using other MCP tools. +4. Show the draft after each section so the user can review and fill in gaps. +5. Produce the final output in the format the space defines. + +### Example 5: Update Space Instructions Programmatically + +**User**: "Update my PM Weekly Updates space to include a new writing guideline" + +**Action**: +1. Call `mcp__github__list_copilot_spaces` and find the space number (e.g., 19). +2. Call `mcp__github__get_copilot_space` to read current instructions. +3. Modify the instructions text as requested. +4. Push the update: +```bash +gh api users/labudis/copilot-spaces/19 -X PUT -f general_instructions="updated instructions..." +``` + +## Tips + +- Space names are **case-sensitive**. Use the exact name from `list_copilot_spaces`. +- Spaces can be owned by users or organizations. Always provide both `owner` and `name`. +- Space content can be large (20KB+). If returned as a temp file, use grep or view_range to find relevant sections rather than reading everything at once. +- If a space isn't found, suggest listing available spaces to find the right name. +- Spaces auto-update as underlying repos change, so the context is always current. +- Some spaces contain custom instructions that should guide your behavior (coding standards, preferred patterns, workflows). Treat these as directives, not suggestions. +- **Write operations** (`gh api` for create/update/delete) require the `user` PAT scope. If you get a 404 on write operations, run `gh auth refresh -h github.com -s user`. +- Resource updates **replace the entire array**. To add a resource, include all existing resources plus the new one. To remove one, include `{ "id": 123, "_destroy": true }` in the array. diff --git a/skills/doublecheck/SKILL.md b/skills/doublecheck/SKILL.md new file mode 100644 index 0000000..1002a1c --- /dev/null +++ b/skills/doublecheck/SKILL.md @@ -0,0 +1,277 @@ +--- +name: doublecheck +description: 'Three-layer verification pipeline for AI output. Extracts verifiable claims, finds supporting or contradicting sources via web search, runs adversarial review for hallucination patterns, and produces a structured verification report with source links for human review.' +--- + +# Doublecheck + +Run a three-layer verification pipeline on AI-generated output. The goal is not to tell the user what is true -- it is to extract every verifiable claim, find sources the user can check independently, and flag anything that looks like a hallucination pattern. + +## Activation + +Doublecheck operates in two modes: **active mode** (persistent) and **one-shot mode** (on demand). + +### Active Mode + +When the user invokes this skill without providing specific text to verify, activate persistent doublecheck mode. Respond with: + +> **Doublecheck is now active.** I'll verify factual claims in my responses before presenting them. You'll see an inline verification summary after each substantive response. Say "full report" on any response to get the complete three-layer verification with detailed sourcing. Turn it off anytime by saying "turn off doublecheck." + +Then follow ALL of the rules below for the remainder of the conversation: + +**Rule: Classify every response before sending it.** + +Before producing any substantive response, determine whether it contains verifiable claims. Classify the response: + +| Response type | Contains verifiable claims? | Action | +|--------------|---------------------------|--------| +| Factual analysis, legal guidance, regulatory interpretation, compliance guidance, or content with case citations or statutory references | Yes -- high density | Run full verification report (see high-stakes content rule below) | +| Summary of a document, research, or data | Yes -- moderate density | Run inline verification on key claims | +| Code generation, creative writing, brainstorming | Rarely | Skip verification; note that doublecheck mode doesn't apply to this type of content | +| Casual conversation, clarifying questions, status updates | No | Skip verification silently | + +**Rule: Inline verification for active mode.** + +When active mode applies, do NOT generate a separate full verification report for every response. Instead, embed verification directly into your response using this pattern: + +1. Generate your response normally. +2. After the response, add a `Verification` section. +3. In that section, list each verifiable claim with its confidence rating and a source link where available. + +Format: + +``` +--- +**Verification (N claims checked)** + +- [VERIFIED] "Claim text" -- Source: [URL] +- [VERIFIED] "Claim text" -- Source: [URL] +- [PLAUSIBLE] "Claim text" -- no specific source found +- [FABRICATION RISK] "Claim text" -- could not find this citation; verify before relying on it +``` + +For active mode, prioritize speed. Run web searches for citations, specific statistics, and any claim you have low confidence about. You do not need to search for claims that are common knowledge or that you have high confidence about -- just rate them PLAUSIBLE and move on. + +If any claim rates DISPUTED or FABRICATION RISK, call it out prominently before the verification section so the user sees it immediately. When auto-escalation applies (see below), place this callout at the top of the full report, before the summary table: + +``` +**Heads up:** I'm not confident about [specific claim]. I couldn't find a supporting source. You should verify this independently before relying on it. +``` + +**Rule: Auto-escalate to full report for high-risk findings.** + +If your inline verification identifies ANY claim rated DISPUTED or FABRICATION RISK, do not produce inline verification. Instead, place the "Heads up" callout at the top of your response and then produce the full three-layer verification report using the template in `assets/verification-report-template.md`. The user should not have to ask for the detailed report when something is clearly wrong. + +**Rule: Full report for high-stakes content.** + +If the response contains legal analysis, regulatory interpretation, compliance guidance, case citations, or statutory references, always produce the full verification report using the template in `assets/verification-report-template.md`. Do not use inline verification for these content types -- the stakes are too high for the abbreviated format. + +**Rule: Discoverability footer for inline verification.** + +When producing inline verification (not a full report), always append this line at the end of the verification section: + +``` +_Say "full report" for detailed three-layer verification with sources._ +``` + +**Rule: Offer full verification on request.** + +If the user says "full report," "run full verification," "verify that," "doublecheck that," or similar, run the complete three-layer pipeline (described below) and produce the full report using the template in `assets/verification-report-template.md`. + +### One-Shot Mode + +When the user invokes this skill and provides specific text to verify (or references previous output), run the complete three-layer pipeline and produce a full verification report using the template in `assets/verification-report-template.md`. + +### Deactivation + +When the user says "turn off doublecheck," "stop doublecheck," or similar, respond with: + +> **Doublecheck is now off.** I'll respond normally without inline verification. You can reactivate it anytime. + +--- + +## Layer 1: Self-Audit + +Re-read the target text with a critical lens. Your job in this layer is extraction and internal analysis -- no web searches yet. + +### Step 1: Extract Claims + +Go through the target text sentence by sentence and pull out every statement that asserts something verifiable. Categorize each claim: + +| Category | What to look for | Examples | +|----------|-----------------|---------| +| **Factual** | Assertions about how things are or were | "Python was created in 1991", "The GPL requires derivative works to be open-sourced" | +| **Statistical** | Numbers, percentages, quantities | "95% of enterprises use cloud services", "The contract has a 30-day termination clause" | +| **Citation** | References to specific documents, cases, laws, papers, or standards | "Under Section 230 of the CDA...", "In *Mayo v. Prometheus* (2012)..." | +| **Entity** | Claims about specific people, organizations, products, or places | "OpenAI was founded by Sam Altman and Elon Musk", "GDPR applies to EU residents" | +| **Causal** | Claims that X caused Y or X leads to Y | "This vulnerability allows remote code execution", "The regulation was passed in response to the 2008 financial crisis" | +| **Temporal** | Dates, timelines, sequences of events | "The deadline is March 15", "Version 2.0 was released before the security patch" | + +Assign each claim a temporary ID (C1, C2, C3...) for tracking through subsequent layers. + +### Step 2: Check Internal Consistency + +Review the extracted claims against each other: +- Does the text contradict itself anywhere? (e.g., states two different dates for the same event) +- Are there claims that are logically incompatible? +- Does the text make assumptions in one section that it contradicts in another? + +Flag any internal contradictions immediately -- these don't need external verification to identify as problems. + +### Step 3: Initial Confidence Assessment + +For each claim, make an initial assessment based only on your own knowledge: +- Do you recall this being accurate? +- Is this the kind of claim where models frequently hallucinate? (Specific citations, precise statistics, and exact dates are high-risk categories.) +- Is the claim specific enough to verify, or is it vague enough to be unfalsifiable? + +Record your initial confidence but do NOT report it as a finding yet. This is input for Layer 2, not output. + +--- + +## Layer 2: Source Verification + +For each extracted claim, search for external evidence. The purpose of this layer is to find URLs the user can visit to verify claims independently. + +### Search Strategy + +For each claim: + +1. **Formulate a search query** that would surface the primary source. For citations, search for the exact title or case name. For statistics, search for the specific number and topic. For factual claims, search for the key entities and relationships. + +2. **Run the search** using `web_search`. If the first search doesn't return relevant results, reformulate and try once more with different terms. + +3. **Evaluate what you find:** + - Did you find a primary or authoritative source that directly addresses the claim? + - Did you find contradicting information from a credible source? + - Did you find nothing relevant? (This is itself a signal -- real things usually have a web footprint.) + +4. **Record the result** with the source URL. Always provide the URL even if you also summarize what the source says. + +### What Counts as a Source + +Prefer primary and authoritative sources: +- Official documentation, specifications, and standards +- Court records, legislative texts, regulatory filings +- Peer-reviewed publications +- Official organizational websites and press releases +- Established reference works (encyclopedias, legal databases) + +Note when a source is secondary (news article, blog post, wiki page) vs. primary. The user can weigh accordingly. + +### Handling Citations Specifically + +Citations are the highest-risk category for hallucinations. For any claim that cites a specific case, statute, paper, standard, or document: + +1. Search for the exact citation (case name, title, section number). +2. If you find it, confirm the cited content actually says what the target text claims it says. +3. If you cannot find it at all, flag it as FABRICATION RISK. Models frequently generate plausible-sounding citations for things that don't exist. + +--- + +## Layer 3: Adversarial Review + +Switch your posture entirely. In Layers 1 and 2, you were trying to understand and verify the output. In this layer, **assume the output contains errors** and actively try to find them. + +### Hallucination Pattern Checklist + +Check for these common patterns: + +1. **Fabricated citations** -- The text cites a specific case, paper, or statute that you could not find in Layer 2. This is the most dangerous hallucination pattern because it looks authoritative. + +2. **Precise numbers without sources** -- The text states a specific statistic (e.g., "78% of companies...") without indicating where the number comes from. Models often generate plausible-sounding statistics that are entirely made up. + +3. **Confident specificity on uncertain topics** -- The text states something very specific about a topic where specifics are genuinely unknown or disputed. Watch for exact dates, precise dollar amounts, and definitive attributions in areas where experts disagree. + +4. **Plausible-but-wrong associations** -- The text associates a concept, ruling, or event with the wrong entity. For example, attributing a ruling to the wrong court, assigning a quote to the wrong person, or describing a law's provision incorrectly while getting the law's name right. + +5. **Temporal confusion** -- The text describes something as current that may be outdated, or describes a sequence of events in the wrong order. + +6. **Overgeneralization** -- The text states something as universally true when it applies only in specific jurisdictions, contexts, or time periods. Common in legal and regulatory content. + +7. **Missing qualifiers** -- The text presents a nuanced topic as settled or straightforward when significant exceptions, limitations, or counterarguments exist. + +### Adversarial Questions + +For each major claim that passed Layers 1 and 2, ask: +- What would make this claim wrong? +- Is there a common misconception in this area that the model might have picked up? +- If I were a subject matter expert, would I object to how this is stated? +- Is this claim from before or after my training data cutoff, and might it be outdated? + +### Red Flags to Escalate + +If you find any of these, flag them prominently in the report: +- A specific citation that cannot be found anywhere +- A statistic with no identifiable source +- A legal or regulatory claim that contradicts what authoritative sources say +- A claim that has been stated with high confidence but is actually disputed or uncertain + +--- + +## Producing the Verification Report + +After completing all three layers, produce the report using the template in `assets/verification-report-template.md`. + +### Confidence Ratings + +Assign each claim a final rating: + +| Rating | Meaning | What the user should do | +|--------|---------|------------------------| +| **VERIFIED** | Supporting source found and linked | Spot-check the source link if the claim is critical to your work | +| **PLAUSIBLE** | Consistent with general knowledge, no specific source found | Treat as reasonable but unconfirmed; verify independently if relying on it for decisions | +| **UNVERIFIED** | Could not find supporting or contradicting evidence | Do not rely on this claim without independent verification | +| **DISPUTED** | Found contradicting evidence from a credible source | Review the contradicting source; this claim may be wrong | +| **FABRICATION RISK** | Matches hallucination patterns (e.g., unfindable citation, unsourced precise statistic) | Assume this is wrong until you can confirm it from a primary source | + +### Report Principles + +- Provide links, not verdicts. The user decides what's true, not you. +- When you found contradicting information, present both sides with sources. Don't pick a winner. +- If a claim is unfalsifiable (too vague or subjective to verify), say so. "Unfalsifiable" is useful information. +- Be explicit about what you could not check. "I could not verify this" is different from "this is wrong." +- Group findings by severity. Lead with the items that need the most attention. + +### Limitations Disclosure + +Always include this at the end of the report: + +> **Limitations of this verification:** +> - This tool accelerates human verification; it does not replace it. +> - Web search results may not include the most recent information or paywalled sources. +> - The adversarial review uses the same underlying model that may have produced the original output. It catches many issues but cannot catch all of them. +> - A claim rated VERIFIED means a supporting source was found, not that the claim is definitely correct. Sources can be wrong too. +> - Claims rated PLAUSIBLE may still be wrong. The absence of contradicting evidence is not proof of accuracy. + +--- + +## Domain-Specific Guidance + +### Legal Content + +Legal content carries elevated hallucination risk because: +- Case names, citations, and holdings are frequently fabricated by models +- Jurisdictional nuances are often flattened or omitted +- Statutory language may be paraphrased in ways that change the legal meaning +- "Majority rule" and "minority rule" distinctions are often lost + +For legal content, give extra scrutiny to: case citations, statutory references, regulatory interpretations, and jurisdictional claims. Search legal databases when possible. + +### Medical and Scientific Content + +- Check that cited studies actually exist and that the results are accurately described +- Watch for outdated guidelines being presented as current +- Flag dosages, treatment protocols, or diagnostic criteria -- these change and errors can be dangerous + +### Financial and Regulatory Content + +- Verify specific dollar amounts, dates, and thresholds +- Check that regulatory requirements are attributed to the correct jurisdiction and are current +- Watch for tax law claims that may be outdated after recent legislative changes + +### Technical and Security Content + +- Verify CVE numbers, vulnerability descriptions, and affected versions +- Check that API specifications and configuration instructions match current documentation +- Watch for version-specific information that may be outdated diff --git a/skills/git-commit/SKILL.md b/skills/git-commit/SKILL.md new file mode 100644 index 0000000..c35f13b --- /dev/null +++ b/skills/git-commit/SKILL.md @@ -0,0 +1,124 @@ +--- +name: git-commit +description: 'Execute git commit with conventional commit message analysis, intelligent staging, and message generation. Use when user asks to commit changes, create a git commit, or mentions "/commit". Supports: (1) Auto-detecting type and scope from changes, (2) Generating conventional commit messages from diff, (3) Interactive commit with optional type/scope/description overrides, (4) Intelligent file staging for logical grouping' +license: MIT +allowed-tools: Bash +--- + +# Git Commit with Conventional Commits + +## Overview + +Create standardized, semantic git commits using the Conventional Commits specification. Analyze the actual diff to determine appropriate type, scope, and message. + +## Conventional Commit Format + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +## Commit Types + +| Type | Purpose | +| ---------- | ------------------------------ | +| `feat` | New feature | +| `fix` | Bug fix | +| `docs` | Documentation only | +| `style` | Formatting/style (no logic) | +| `refactor` | Code refactor (no feature/fix) | +| `perf` | Performance improvement | +| `test` | Add/update tests | +| `build` | Build system/dependencies | +| `ci` | CI/config changes | +| `chore` | Maintenance/misc | +| `revert` | Revert commit | + +## Breaking Changes + +``` +# Exclamation mark after type/scope +feat!: remove deprecated endpoint + +# BREAKING CHANGE footer +feat: allow config to extend other configs + +BREAKING CHANGE: `extends` key behavior changed +``` + +## Workflow + +### 1. Analyze Diff + +```bash +# If files are staged, use staged diff +git diff --staged + +# If nothing staged, use working tree diff +git diff + +# Also check status +git status --porcelain +``` + +### 2. Stage Files (if needed) + +If nothing is staged or you want to group changes differently: + +```bash +# Stage specific files +git add path/to/file1 path/to/file2 + +# Stage by pattern +git add *.test.* +git add src/components/* + +# Interactive staging +git add -p +``` + +**Never commit secrets** (.env, credentials.json, private keys). + +### 3. Generate Commit Message + +Analyze the diff to determine: + +- **Type**: What kind of change is this? +- **Scope**: What area/module is affected? +- **Description**: One-line summary of what changed (present tense, imperative mood, <72 chars) + +### 4. Execute Commit + +```bash +# Single line +git commit -m "[scope]: " + +# Multi-line with body/footer +git commit -m "$(cat <<'EOF' +[scope]: + + + + +EOF +)" +``` + +## Best Practices + +- One logical change per commit +- Present tense: "add" not "added" +- Imperative mood: "fix bug" not "fixes bug" +- Reference issues: `Closes #123`, `Refs #456` +- Keep description under 72 characters + +## Git Safety Protocol + +- NEVER update git config +- NEVER run destructive commands (--force, hard reset) without explicit request +- NEVER skip hooks (--no-verify) unless user asks +- NEVER force push to main/master +- If commit fails due to hooks, fix and create NEW commit (don't amend) diff --git a/skills/github-issues/SKILL.md b/skills/github-issues/SKILL.md new file mode 100644 index 0000000..4619bac --- /dev/null +++ b/skills/github-issues/SKILL.md @@ -0,0 +1,201 @@ +--- +name: github-issues +description: 'Create, update, and manage GitHub issues using MCP tools. Use this skill when users want to create bug reports, feature requests, or task issues, update existing issues, add labels/assignees/milestones, set issue fields (dates, priority, custom fields), set issue types, manage issue workflows, link issues, add dependencies, or track blocked-by/blocking relationships. Triggers on requests like "create an issue", "file a bug", "request a feature", "update issue X", "set the priority", "set the start date", "link issues", "add dependency", "blocked by", "blocking", or any GitHub issue management task.' +--- + +# GitHub Issues + +Manage GitHub issues using the `@modelcontextprotocol/server-github` MCP server. + +## Available Tools + +### MCP Tools (read operations) + +| Tool | Purpose | +|------|---------| +| `mcp__github__issue_read` | Read issue details, sub-issues, comments, labels (methods: get, get_comments, get_sub_issues, get_labels) | +| `mcp__github__list_issues` | List and filter repository issues by state, labels, date | +| `mcp__github__search_issues` | Search issues across repos using GitHub search syntax | +| `mcp__github__projects_list` | List projects, project fields, project items, status updates | +| `mcp__github__projects_get` | Get details of a project, field, item, or status update | +| `mcp__github__projects_write` | Add/update/delete project items, create status updates | + +### CLI / REST API (write operations) + +The MCP server does not currently support creating, updating, or commenting on issues. Use `gh api` for these operations. + +| Operation | Command | +|-----------|---------| +| Create issue | `gh api repos/{owner}/{repo}/issues -X POST -f title=... -f body=...` | +| Update issue | `gh api repos/{owner}/{repo}/issues/{number} -X PATCH -f title=... -f state=...` | +| Add comment | `gh api repos/{owner}/{repo}/issues/{number}/comments -X POST -f body=...` | +| Close issue | `gh api repos/{owner}/{repo}/issues/{number} -X PATCH -f state=closed` | +| Set issue type | Include `-f type=Bug` in the create call (REST API only, not supported by `gh issue create` CLI) | + +**Note:** `gh issue create` works for basic issue creation but does **not** support the `--type` flag. Use `gh api` when you need to set issue types. + +## Workflow + +1. **Determine action**: Create, update, or query? +2. **Gather context**: Get repo info, existing labels, milestones if needed +3. **Structure content**: Use appropriate template from [references/templates.md](references/templates.md) +4. **Execute**: Use MCP tools for reads, `gh api` for writes +5. **Confirm**: Report the issue URL to user + +## Creating Issues + +Use `gh api` to create issues. This supports all parameters including issue types. + +```bash +gh api repos/{owner}/{repo}/issues \ + -X POST \ + -f title="Issue title" \ + -f body="Issue body in markdown" \ + -f type="Bug" \ + --jq '{number, html_url}' +``` + +### Optional Parameters + +Add any of these flags to the `gh api` call: + +``` +-f type="Bug" # Issue type (Bug, Feature, Task, Epic, etc.) +-f labels[]="bug" # Labels (repeat for multiple) +-f assignees[]="username" # Assignees (repeat for multiple) +-f milestone=1 # Milestone number +``` + +**Issue types** are organization-level metadata. To discover available types, use: +```bash +gh api graphql -f query='{ organization(login: "ORG") { issueTypes(first: 10) { nodes { name } } } }' --jq '.data.organization.issueTypes.nodes[].name' +``` + +**Prefer issue types over labels for categorization.** When issue types are available (e.g., Bug, Feature, Task), use the `type` parameter instead of applying equivalent labels like `bug` or `enhancement`. Issue types are the canonical way to categorize issues on GitHub. Only fall back to labels when the org has no issue types configured. + +### Title Guidelines + +- Be specific and actionable +- Keep under 72 characters +- When issue types are set, don't add redundant prefixes like `[Bug]` +- Examples: + - `Login fails with SSO enabled` (with type=Bug) + - `Add dark mode support` (with type=Feature) + - `Add unit tests for auth module` (with type=Task) + +### Body Structure + +Always use the templates in [references/templates.md](references/templates.md). Choose based on issue type: + +| User Request | Template | +|--------------|----------| +| Bug, error, broken, not working | Bug Report | +| Feature, enhancement, add, new | Feature Request | +| Task, chore, refactor, update | Task | + +## Updating Issues + +Use `gh api` with PATCH: + +```bash +gh api repos/{owner}/{repo}/issues/{number} \ + -X PATCH \ + -f state=closed \ + -f title="Updated title" \ + --jq '{number, html_url}' +``` + +Only include fields you want to change. Available fields: `title`, `body`, `state` (open/closed), `labels`, `assignees`, `milestone`. + +## Examples + +### Example 1: Bug Report + +**User**: "Create a bug issue - the login page crashes when using SSO" + +**Action**: +```bash +gh api repos/github/awesome-copilot/issues \ + -X POST \ + -f title="Login page crashes when using SSO" \ + -f type="Bug" \ + -f body="## Description +The login page crashes when users attempt to authenticate using SSO. + +## Steps to Reproduce +1. Navigate to login page +2. Click 'Sign in with SSO' +3. Page crashes + +## Expected Behavior +SSO authentication should complete and redirect to dashboard. + +## Actual Behavior +Page becomes unresponsive and displays error." \ + --jq '{number, html_url}' +``` + +### Example 2: Feature Request + +**User**: "Create a feature request for dark mode with high priority" + +**Action**: +```bash +gh api repos/github/awesome-copilot/issues \ + -X POST \ + -f title="Add dark mode support" \ + -f type="Feature" \ + -f labels[]="high-priority" \ + -f body="## Summary +Add dark mode theme option for improved user experience and accessibility. + +## Motivation +- Reduces eye strain in low-light environments +- Increasingly expected by users + +## Proposed Solution +Implement theme toggle with system preference detection. + +## Acceptance Criteria +- [ ] Toggle switch in settings +- [ ] Persists user preference +- [ ] Respects system preference by default" \ + --jq '{number, html_url}' +``` + +## Common Labels + +Use these standard labels when applicable: + +| Label | Use For | +|-------|---------| +| `bug` | Something isn't working | +| `enhancement` | New feature or improvement | +| `documentation` | Documentation updates | +| `good first issue` | Good for newcomers | +| `help wanted` | Extra attention needed | +| `question` | Further information requested | +| `wontfix` | Will not be addressed | +| `duplicate` | Already exists | +| `high-priority` | Urgent issues | + +## Tips + +- Always confirm the repository context before creating issues +- Ask for missing critical information rather than guessing +- Link related issues when known: `Related to #123` +- For updates, fetch current issue first to preserve unchanged fields + +## Extended Capabilities + +The following features require REST or GraphQL APIs beyond the basic MCP tools. Each is documented in its own reference file so the agent only loads the knowledge it needs. + +| Capability | When to use | Reference | +|------------|-------------|-----------| +| Advanced search | Complex queries with boolean logic, date ranges, cross-repo search, issue field filters (`field.name:value`) | [references/search.md](references/search.md) | +| Sub-issues & parent issues | Breaking work into hierarchical tasks | [references/sub-issues.md](references/sub-issues.md) | +| Issue dependencies | Tracking blocked-by / blocking relationships | [references/dependencies.md](references/dependencies.md) | +| Issue types (advanced) | GraphQL operations beyond MCP `list_issue_types` / `type` param | [references/issue-types.md](references/issue-types.md) | +| Projects V2 | Project boards, progress reports, field management | [references/projects.md](references/projects.md) | +| Issue fields | Custom metadata: dates, priority, text, numbers (private preview) | [references/issue-fields.md](references/issue-fields.md) | +| Images in issues | Embedding images in issue bodies and comments via CLI | [references/images.md](references/images.md) | diff --git a/src/app.css b/src/app.css index c2ee0eb..6300596 100644 --- a/src/app.css +++ b/src/app.css @@ -32,7 +32,6 @@ --sp-4: 16px; --sp-5: 20px; --sp-6: 24px; - --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif; --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', 'Menlo', 'Consolas', monospace; --font-size: clamp(14px, 3.8vw, 16px); --line-height: 1.6; @@ -97,7 +96,7 @@ html, body { height: 100%; background: var(--bg); color: var(--fg); - font-family: var(--font-sans); + font-family: var(--font-mono); font-size: var(--font-size); line-height: var(--line-height); overflow: hidden; @@ -107,20 +106,6 @@ html, body { touch-action: manipulation; } -/* ── Utility: hide scrollbar ──────────────────────────────────────────────── */ -.scrollbar-hidden { - scrollbar-width: none; -} -.scrollbar-hidden::-webkit-scrollbar { - display: none; -} - -/* ── Utility: focus ring ──────────────────────────────────────────────────── */ -:focus-visible { - outline: 2px solid var(--color-accent); - outline-offset: 2px; -} - /* ── Shared shimmer loading animation ─────────────────────────────────────── */ @keyframes shimmer { 0% { background-position: -200% 0; } diff --git a/src/app.d.ts b/src/app.d.ts index 8620f37..941ddd4 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -6,59 +6,6 @@ declare global { session: SessionData | null; } } - - // Web Speech API types (not included in TypeScript's DOM lib) - interface SpeechRecognitionEvent extends Event { - readonly resultIndex: number; - readonly results: SpeechRecognitionResultList; - } - - interface SpeechRecognitionResultList { - readonly length: number; - item(index: number): SpeechRecognitionResult; - [index: number]: SpeechRecognitionResult; - } - - interface SpeechRecognitionResult { - readonly isFinal: boolean; - readonly length: number; - item(index: number): SpeechRecognitionAlternative; - [index: number]: SpeechRecognitionAlternative; - } - - interface SpeechRecognitionAlternative { - readonly transcript: string; - readonly confidence: number; - } - - interface SpeechRecognitionErrorEvent extends Event { - readonly error: string; - readonly message: string; - } - - interface SpeechRecognition extends EventTarget { - continuous: boolean; - interimResults: boolean; - lang: string; - onresult: ((event: SpeechRecognitionEvent) => void) | null; - onend: (() => void) | null; - onerror: ((event: SpeechRecognitionErrorEvent) => void) | null; - start(): void; - stop(): void; - abort(): void; - } - - interface SpeechRecognitionConstructor { - new(): SpeechRecognition; - } - - // eslint-disable-next-line no-var - var SpeechRecognition: SpeechRecognitionConstructor | undefined; - - interface Window { - SpeechRecognition?: SpeechRecognitionConstructor; - webkitSpeechRecognition?: SpeechRecognitionConstructor; - } } export {}; diff --git a/src/hooks.server.test.ts b/src/hooks.server.test.ts index ba241f0..e22281d 100644 --- a/src/hooks.server.test.ts +++ b/src/hooks.server.test.ts @@ -194,24 +194,45 @@ describe('handle', () => { }); }); - describe('security headers (hook-emitted)', () => { - // CSP (including nonces) is emitted by SvelteKit via svelte.config.js → kit.csp, - // not by the hook. The hook only emits HSTS + always-on headers. - it('adds HSTS in production', async () => { + describe('content security policy', () => { + it('adds the Content-Security-Policy header in production', async () => { setProductionEnv(); const handle = await loadHandle(); const response = await handle({ event: createEvent(), resolve: createResolve() }); - expect(response.headers.get('Strict-Transport-Security')).toBe('max-age=31536000; includeSubDomains'); + expect(response.headers.has('Content-Security-Policy')).toBe(true); }); - it('skips HSTS in development while still applying always-on security headers', async () => { + it('allows self for default-src and unsafe-inline for style-src', async () => { + setProductionEnv(); + const handle = await loadHandle(); + + const response = await handle({ event: createEvent(), resolve: createResolve() }); + const csp = response.headers.get('Content-Security-Policy'); + + expect(csp).toContain("default-src 'self'"); + expect(csp).toContain("style-src 'self' 'unsafe-inline'"); + }); + + it('allows websocket connections and GitHub avatars in production CSP', async () => { + setProductionEnv(); + const handle = await loadHandle(); + + const response = await handle({ event: createEvent(), resolve: createResolve() }); + const csp = response.headers.get('Content-Security-Policy'); + + expect(csp).toContain("connect-src 'self' ws: wss:"); + expect(csp).toContain("img-src 'self' data: blob: https://avatars.githubusercontent.com"); + }); + + it('skips strict CSP in development while still applying always-on security headers', async () => { setEnv('NODE_ENV', 'development'); const handle = await loadHandle(); const response = await handle({ event: createEvent(), resolve: createResolve() }); + expect(response.headers.get('Content-Security-Policy')).toBeNull(); expect(response.headers.get('Strict-Transport-Security')).toBeNull(); expect(response.headers.get('X-Frame-Options')).toBe('DENY'); expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff'); @@ -343,6 +364,7 @@ describe('handle', () => { expect(response.status).toBe(429); expect(await response.text()).toBe('Too Many Requests'); expect(response.headers.get('Retry-After')).toBe('900'); + expect(response.headers.get('Content-Security-Policy')).toContain("default-src 'self'"); }); it('tracks different IP addresses independently', async () => { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index bae1469..beac2cb 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -5,17 +5,16 @@ import { checkAuth, revalidateTokenIfStale } from '$lib/server/auth/guard.js'; import { unsealAuth, parseCookieValue, AUTH_COOKIE_NAME } from '$lib/server/auth/auth-cookie.js'; import { config } from '$lib/server/config.js'; import { initServerSideEffects } from '$lib/server/init.js'; -import { debug } from '$lib/server/logger.js'; initServerSideEffects(); // Extract express-session from the bridge and attach to event.locals const sessionHandle: Handle = async ({ event, resolve }) => { const sessionId = event.request.headers.get('x-session-id'); - debug(`[HOOKS] ${event.request.method} ${event.url.pathname} sessionId=${sessionId ?? 'NONE'}`); + console.log(`[HOOKS] ${event.request.method} ${event.url.pathname} sessionId=${sessionId ?? 'NONE'}`); if (sessionId) { const session = getSessionById(sessionId) ?? null; - debug(`[HOOKS] session resolved: hasSession=${!!session} hasToken=${!!session?.githubToken} user=${session?.githubUser?.login ?? 'none'}`); + console.log(`[HOOKS] session resolved: hasSession=${!!session} hasToken=${!!session?.githubToken} user=${session?.githubUser?.login ?? 'none'}`); // Restore auth from encrypted cookie when session file was lost (e.g. deploy wipe) if (session && !session.githubToken) { @@ -27,14 +26,14 @@ const sessionHandle: Handle = async ({ event, resolve }) => { session.githubUser = data.githubUser; session.githubAuthTime = data.githubAuthTime; session.save(() => {}); - debug(`[HOOKS] Restored auth from cookie for user=${data.githubUser.login}`); + console.log(`[HOOKS] Restored auth from cookie for user=${data.githubUser.login}`); } } } event.locals.session = session; } else { - debug(`[HOOKS] NO x-session-id header — session will be null`); + console.log(`[HOOKS] NO x-session-id header — session will be null`); event.locals.session = null; } return resolve(event); @@ -45,9 +44,23 @@ const securityHeaders: Handle = async ({ event, resolve }) => { const isDev = process.env.NODE_ENV !== 'production'; - // CSP (including nonces) is emitted by SvelteKit via svelte.config.js → kit.csp. - // HSTS is emitted here because it's not a CSP directive. + // Skip strict CSP in dev mode — Vite HMR uses blob: workers and eval if (!isDev) { + response.headers.set('Content-Security-Policy', [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "connect-src 'self' ws: wss: https://*.push.services.mozilla.com https://*.push.apple.com https://fcm.googleapis.com https://*.notify.windows.com", + "worker-src 'self' blob:", + "img-src 'self' data: blob: https://avatars.githubusercontent.com", + "font-src 'self'", + "frame-ancestors 'none'", + "form-action 'self'", + "base-uri 'self'", + "manifest-src 'self'", + "object-src 'none'", + ].join('; ')); + response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } @@ -55,7 +68,6 @@ const securityHeaders: Handle = async ({ event, resolve }) => { response.headers.set('X-Frame-Options', 'DENY'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); - response.headers.set('Permissions-Policy', 'camera=(), microphone=(self), geolocation=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()'); return response; }; @@ -103,11 +115,6 @@ setInterval(() => { }, RATE_LIMIT_WINDOW); const rateLimit: Handle = async ({ event, resolve }) => { - // Never allow test-only rate-limit bypass in production. - if (process.env.E2E_DISABLE_RATE_LIMIT === 'true' && process.env.NODE_ENV !== 'production') { - return resolve(event); - } - const ip = event.getClientAddress(); const now = Date.now(); diff --git a/src/lib/components/layout/Banner.svelte b/src/lib/components/Banner.svelte similarity index 100% rename from src/lib/components/layout/Banner.svelte rename to src/lib/components/Banner.svelte diff --git a/src/lib/components/ChatInput.svelte b/src/lib/components/ChatInput.svelte new file mode 100644 index 0000000..0f6854d --- /dev/null +++ b/src/lib/components/ChatInput.svelte @@ -0,0 +1,1652 @@ + + +
+ + + + {#if selectedFiles.length > 0} +
+ {#each selectedFiles as file, i (file.name + i)} +
+ {#if isImageFile(file)} + {file.name} URL.revokeObjectURL((e.currentTarget as HTMLImageElement).src)} + /> + {:else} + + {/if} + {file.name} + {#if !isImageFile(file)} + {formatFileSize(file.size)} + {/if} + +
+ {/each} +
+ {#if hasImageAttachments && !supportsVision} + + {/if} + {/if} + +
+ {#if pendingUserInput} +
+ {pendingUserInput.question} + {#if pendingUserInput.choices && pendingUserInput.choices.length > 0} +
+ {#each pendingUserInput.choices as choice (choice)} + + {/each} +
+ {/if} +
+ {/if} + + + + {#if showSlashHint && filteredSlashCommands.length > 0} +
+ {#each filteredSlashCommands as cmd, i (cmd.cmd)} + + {/each} +
+ {/if} + + {#if mentionOpen} +
+ {#if mentionLoading && mentionFiles.length === 0} +
Searching files…
+ {:else if mentionError && mentionFiles.length === 0} +
{mentionError}
+ {:else if mentionFiles.length === 0} +
No files found
+ {:else} +
    + {#each mentionFiles.slice(0, 8) as file, i (file)} +
  • { e.preventDefault(); selectMentionFile(file); }} + onmouseenter={() => { mentionIndex = i; }} + > + + {file} +
  • + {/each} +
+ {#if mentionFiles.length > 8} +
{mentionFiles.length - 8} more…
+ {/if} + {/if} +
+ {/if} + + {#if issueOpen} +
+ {#if issueLoading && issueResults.length === 0} +
Searching issues…
+ {:else if issueError && issueResults.length === 0} +
{issueError}
+ {:else if issueResults.length === 0} +
No issues found
+ {:else} +
    + {#each issueResults.slice(0, 8) as issue, i (`${issue.repo ?? ''}#${issue.number}`)} +
  • { e.preventDefault(); selectIssue(issue); }} + onmouseenter={() => { issueIndex = i; }} + > + + {#if issue.repo} + {issue.repo} + {/if} + #{issue.number} + {issue.title} + {issue.state} +
  • + {/each} +
+ {#if issueResults.length > 8} +
{issueResults.length - 8} more…
+ {/if} + {/if} +
+ {/if} + + {#if showSteeringIndicator} +
+ Message will be queued and sent when the current response completes. +
+ {/if} + +
+
+ {#if !pendingUserInput} +
+ + + {#if attachMenuOpen} + + + + + {/if} +
+ + {/if} + +
+ {#each modes as m (m.value)} + + {/each} +
+
+ +
+ {#if isStreaming || isWaiting} + {#if !pendingUserInput && canSend} + + {/if} + + {:else} + + {/if} +
+
+
+
+ +{#if showHelp} + +
showHelp = false} role="presentation">
+ +{/if} + + diff --git a/src/lib/components/chat/ChatMessage.svelte b/src/lib/components/ChatMessage.svelte similarity index 89% rename from src/lib/components/chat/ChatMessage.svelte rename to src/lib/components/ChatMessage.svelte index a94cac1..e84d0ea 100644 --- a/src/lib/components/chat/ChatMessage.svelte +++ b/src/lib/components/ChatMessage.svelte @@ -1,23 +1,18 @@ + +
+ {#each tools as tool, index (tool.name)} +
+ + + {#if expandedIndex === index} +
+ Name + + + Description + + + Method + + + Webhook URL + + + Headers + {#each draftHeaders as header, hi (hi)} +
+ + + +
+ {/each} + + + Parameters + {#each draftParams as param, pi (pi)} +
+ + + + +
+ {/each} + + + {#if formError} +
{formError}
+ {/if} + +
+ + {#if deleteConfirmIndex === index} + + + {:else} + + {/if} +
+
+ {/if} +
+ {/each} + + {#if showAddForm} +
+
+ Name + + + Description + + + Method + + + Webhook URL + + + Headers + {#each draftHeaders as header, hi (hi)} +
+ + + +
+ {/each} + + + Parameters + {#each draftParams as param, pi (pi)} +
+ + + + +
+ {/each} + + + {#if formError} +
{formError}
+ {/if} + +
+ + +
+
+
+ {/if} + + {#if canAddMore && !showAddForm} + + {/if} + + {#if !canAddMore && !showAddForm} +
+ Maximum of {MAX_TOOLS} tools reached +
+ {/if} +
+ + diff --git a/src/lib/components/auth/DeviceFlowLogin.svelte b/src/lib/components/DeviceFlowLogin.svelte similarity index 96% rename from src/lib/components/auth/DeviceFlowLogin.svelte rename to src/lib/components/DeviceFlowLogin.svelte index df90693..cd84837 100644 --- a/src/lib/components/auth/DeviceFlowLogin.svelte +++ b/src/lib/components/DeviceFlowLogin.svelte @@ -1,5 +1,4 @@ -
-
- {@render children?.()} +
+ {@render children?.()} - {#each chatStore.messages as msg (msg.id)} - + {#each chatStore.messages as msg (msg.id)} + {/each} {#if hasReasoningContent} @@ -104,42 +93,23 @@ {#if showWaiting}
- + {BRAILLE_FRAMES[spinnerIndex]} Thinking
{/if} {#if chatStore.currentStreamContent}
- Copilot + ◆ Copilot
{@html streamHtml}
{/if} -
- - {#if showScrollButton} - - {/if}
diff --git a/src/lib/components/model/ModelSheet.svelte b/src/lib/components/ModelSheet.svelte similarity index 85% rename from src/lib/components/model/ModelSheet.svelte rename to src/lib/components/ModelSheet.svelte index bcb9b69..8110ff2 100644 --- a/src/lib/components/model/ModelSheet.svelte +++ b/src/lib/components/ModelSheet.svelte @@ -1,6 +1,5 @@ @@ -60,12 +60,6 @@ {formatPath(detail.cwd)}
{/if} - {#if detail.isRemote} -
- Source - Remote / cloud -
- {/if} {#if detail.createdAt || detail.updatedAt}
Last active @@ -131,10 +125,16 @@ flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-4); + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; min-height: 0; } + .preview-body::-webkit-scrollbar { width: 4px; } + .preview-body::-webkit-scrollbar-track { background: transparent; } + .preview-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } .preview-summary { + font-family: var(--font-mono); font-size: 0.9em; color: var(--fg); line-height: 1.5; @@ -154,6 +154,7 @@ display: flex; justify-content: space-between; align-items: baseline; + font-family: var(--font-mono); font-size: 0.8em; gap: var(--sp-2); } @@ -164,7 +165,6 @@ } .meta-value { - font-family: var(--font-mono); color: var(--fg-muted); text-align: right; overflow: hidden; @@ -176,10 +176,6 @@ color: var(--accent); } - .meta-value.remote { - color: var(--purple, #a78bfa); - } - .preview-section { margin-top: var(--sp-3); padding-top: var(--sp-3); @@ -187,6 +183,7 @@ } .section-header { + font-family: var(--font-mono); font-size: 0.78em; font-weight: 600; color: var(--fg-muted); @@ -205,11 +202,11 @@ display: flex; align-items: baseline; gap: var(--sp-2); + font-family: var(--font-mono); font-size: 0.82em; } .checkpoint-number { - font-family: var(--font-mono); color: var(--fg-dim); flex-shrink: 0; min-width: 1.5em; @@ -224,6 +221,7 @@ } .plan-preview { + font-family: var(--font-mono); font-size: 0.78em; color: var(--fg-muted); white-space: pre-wrap; diff --git a/src/lib/components/sessions/SessionsSheet.svelte b/src/lib/components/SessionsSheet.svelte similarity index 69% rename from src/lib/components/sessions/SessionsSheet.svelte rename to src/lib/components/SessionsSheet.svelte index 72e139b..d7e7bee 100644 --- a/src/lib/components/sessions/SessionsSheet.svelte +++ b/src/lib/components/SessionsSheet.svelte @@ -1,7 +1,6 @@ @@ -188,9 +157,7 @@ {:else} Sessions {/if} - +
{#if selectedSessionId} @@ -206,41 +173,6 @@ {:else}
- {#if onNewCloudSession} -
- - {#if cloudFormOpen} -
-

Runs on GitHub's cloud agent against a repository.

-
- - - -
- {#if cloudError} - - {/if} - -
- {/if} -
- {/if} - {#if !loading && sessions.length > 5} diff --git a/src/lib/components/layout/TopBar.svelte b/src/lib/components/TopBar.svelte similarity index 64% rename from src/lib/components/layout/TopBar.svelte rename to src/lib/components/TopBar.svelte index 8bc64c8..1099fda 100644 --- a/src/lib/components/layout/TopBar.svelte +++ b/src/lib/components/TopBar.svelte @@ -1,41 +1,47 @@
{#if sessionTitle} @@ -47,18 +53,24 @@ {/if} - + + {#if activeSkillCount > 0} + + ⚡ {activeSkillCount} + + {/if}
diff --git a/src/lib/components/chat/ChatInput.svelte b/src/lib/components/chat/ChatInput.svelte deleted file mode 100644 index 34ed9c9..0000000 --- a/src/lib/components/chat/ChatInput.svelte +++ /dev/null @@ -1,954 +0,0 @@ - - -
- - - -
- - -{#if voiceInputEnabled} - -{/if} - - - - diff --git a/src/lib/components/chat/ElicitationDialog.svelte b/src/lib/components/chat/ElicitationDialog.svelte deleted file mode 100644 index 56d0af4..0000000 --- a/src/lib/components/chat/ElicitationDialog.svelte +++ /dev/null @@ -1,499 +0,0 @@ - - - - - - diff --git a/src/lib/components/chat/IssueAutocomplete.svelte b/src/lib/components/chat/IssueAutocomplete.svelte deleted file mode 100644 index 7de6af5..0000000 --- a/src/lib/components/chat/IssueAutocomplete.svelte +++ /dev/null @@ -1,305 +0,0 @@ - - -{#if issueOpen} -
- {#if issueLoading && issueResults.length === 0} -
Searching issues…
- {:else if issueError && issueResults.length === 0} -
{issueError}
- {:else if issueResults.length === 0} -
No issues found
- {:else} -
    - {#each issueResults.slice(0, 8) as issue, i (`${issue.repo ?? ''}#${issue.number}`)} -
  • { e.preventDefault(); selectIssue(issue); }} - onmouseenter={() => { issueIndex = i; }} - > - - {#if issue.repo} - {issue.repo} - {/if} - #{issue.number} - {issue.title} - {issue.state} -
  • - {/each} -
- {#if issueResults.length > 8} -
{issueResults.length - 8} more…
- {/if} - {/if} -
-{/if} - - diff --git a/src/lib/components/chat/KeyboardShortcutsHelp.svelte b/src/lib/components/chat/KeyboardShortcutsHelp.svelte deleted file mode 100644 index ca44713..0000000 --- a/src/lib/components/chat/KeyboardShortcutsHelp.svelte +++ /dev/null @@ -1,157 +0,0 @@ - - -{#if showHelp} - -
showHelp = false} role="presentation">
- -{/if} - - diff --git a/src/lib/components/chat/MentionAutocomplete.svelte b/src/lib/components/chat/MentionAutocomplete.svelte deleted file mode 100644 index 60026e8..0000000 --- a/src/lib/components/chat/MentionAutocomplete.svelte +++ /dev/null @@ -1,258 +0,0 @@ - - -{#if mentionOpen} -
- {#if mentionLoading && mentionFiles.length === 0} -
Searching files…
- {:else if mentionError && mentionFiles.length === 0} -
{mentionError}
- {:else if mentionFiles.length === 0} -
No files found
- {:else} -
    - {#each mentionFiles.slice(0, 8) as file, i (file)} -
  • { e.preventDefault(); selectMentionFile(file); }} - onmouseenter={() => { mentionIndex = i; }} - > - - {file} -
  • - {/each} -
- {#if mentionFiles.length > 8} -
{mentionFiles.length - 8} more…
- {/if} - {/if} -
-{/if} - - diff --git a/src/lib/components/chat/MessageActions.svelte b/src/lib/components/chat/MessageActions.svelte deleted file mode 100644 index 05df487..0000000 --- a/src/lib/components/chat/MessageActions.svelte +++ /dev/null @@ -1,143 +0,0 @@ - - - - - diff --git a/src/lib/components/chat/PromptAutocomplete.svelte b/src/lib/components/chat/PromptAutocomplete.svelte deleted file mode 100644 index 2b839ba..0000000 --- a/src/lib/components/chat/PromptAutocomplete.svelte +++ /dev/null @@ -1,215 +0,0 @@ - - -{#if promptOpen && promptFiltered.length > 0} -
-
    - {#each promptFiltered.slice(0, 8) as prompt, i (prompt.name)} -
  • { e.preventDefault(); selectSlashPrompt(prompt); }} - onmouseenter={() => { promptIndex = i; }} - > - - {prompt.description} -
  • - {/each} -
- {#if promptFiltered.length > 8} -
{promptFiltered.length - 8} more…
- {/if} -
-{/if} - - diff --git a/src/lib/components/chat/SlashCommandPalette.svelte b/src/lib/components/chat/SlashCommandPalette.svelte deleted file mode 100644 index c2d890e..0000000 --- a/src/lib/components/chat/SlashCommandPalette.svelte +++ /dev/null @@ -1,195 +0,0 @@ - - -{#if showSlashHint && filteredSlashCommands.length > 0} -
- {#each filteredSlashCommands as cmd, i (cmd.cmd)} - - {/each} -
-{/if} - - diff --git a/src/lib/components/chat/VoiceInput.svelte b/src/lib/components/chat/VoiceInput.svelte deleted file mode 100644 index 6b7df8f..0000000 --- a/src/lib/components/chat/VoiceInput.svelte +++ /dev/null @@ -1,112 +0,0 @@ - - - diff --git a/src/lib/components/chat/VoiceInput.test.ts b/src/lib/components/chat/VoiceInput.test.ts deleted file mode 100644 index d78741f..0000000 --- a/src/lib/components/chat/VoiceInput.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -function createMockRecognition() { - return { - continuous: false, - interimResults: false, - lang: '', - onresult: null as ((e: unknown) => void) | null, - onend: null as (() => void) | null, - onerror: null as ((e: unknown) => void) | null, - start: vi.fn(), - stop: vi.fn(), - abort: vi.fn(), - }; -} - -let mockInstance: ReturnType; - -class MockSpeechRecognition { - continuous = false; - interimResults = false; - lang = ''; - onresult: ((e: unknown) => void) | null = null; - onend: (() => void) | null = null; - onerror: ((e: unknown) => void) | null = null; - start = vi.fn(); - stop = vi.fn(); - abort = vi.fn(); - - constructor() { - mockInstance = this as unknown as ReturnType; - } -} - -describe('VoiceInput', () => { - beforeEach(() => { - vi.stubGlobal('SpeechRecognition', MockSpeechRecognition); - vi.stubGlobal('webkitSpeechRecognition', undefined); - }); - - it('SpeechRecognition mock is set up correctly', () => { - expect(globalThis.SpeechRecognition).toBeDefined(); - const instance = new MockSpeechRecognition(); - expect(instance.start).toBeDefined(); - expect(instance.stop).toBeDefined(); - expect(instance.abort).toBeDefined(); - expect(mockInstance).toBe(instance); - }); - - it('mock recognition fires events correctly', () => { - const instance = new MockSpeechRecognition(); - const handler = vi.fn(); - instance.onresult = handler; - - const event = { - resultIndex: 0, - results: [{ - isFinal: true, - 0: { transcript: 'hello world', confidence: 0.95 }, - length: 1, - }], - }; - - instance.onresult(event); - expect(handler).toHaveBeenCalledWith(event); - }); - - it('mock recognition handles error events', () => { - const instance = new MockSpeechRecognition(); - const handler = vi.fn(); - instance.onerror = handler; - - const errorEvent = { error: 'no-speech', message: 'No speech detected' }; - instance.onerror(errorEvent); - expect(handler).toHaveBeenCalledWith(errorEvent); - }); - - it('mock recognition handles end events', () => { - const instance = new MockSpeechRecognition(); - const handler = vi.fn(); - instance.onend = handler; - - instance.onend(); - expect(handler).toHaveBeenCalled(); - }); -}); diff --git a/src/lib/components/layout/RemoteBanner.svelte b/src/lib/components/layout/RemoteBanner.svelte deleted file mode 100644 index b45b367..0000000 --- a/src/lib/components/layout/RemoteBanner.svelte +++ /dev/null @@ -1,82 +0,0 @@ - - -
-
- - diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte deleted file mode 100644 index 8c996fc..0000000 --- a/src/lib/components/layout/Sidebar.svelte +++ /dev/null @@ -1,500 +0,0 @@ - - - - - -{#if open} - - -{/if} - - - - diff --git a/src/lib/components/settings/AgentsPanel.svelte b/src/lib/components/settings/AgentsPanel.svelte deleted file mode 100644 index 29640a5..0000000 --- a/src/lib/components/settings/AgentsPanel.svelte +++ /dev/null @@ -1,130 +0,0 @@ - - -

- Click an agent to activate it for the current session. The model will use the agent's system prompt for all subsequent messages. Click again to deactivate. -

-{#if loading} -
-
-
-
-
-{:else if agents.length === 0} -

No agents found. Add .agent.md files to ~/.copilot/agents/.

-{:else} - {#each [...groupBySource(agents).entries()] as [source, items] (source)} -
-
{items.length}
- {#each items as agent (agent.name)} - - {/each} -
- {/each} -{/if} - - diff --git a/src/lib/components/settings/ByokPanel.svelte b/src/lib/components/settings/ByokPanel.svelte deleted file mode 100644 index 4063499..0000000 --- a/src/lib/components/settings/ByokPanel.svelte +++ /dev/null @@ -1,392 +0,0 @@ - - -

- Connect your own AI provider (OpenAI-compatible, Azure, or Anthropic). API keys are encrypted at rest. -

- -{#if loading} -
-
-
-
-{:else} - {#if isConfigured} -
- - Provider configured - {#if provider?.type} - ({provider.type}) - {/if} -
- {:else} -
- - No provider configured -
- {/if} - -
- - - - - - - - - - - {#if provider?.hasApiKey} -

API key saved ✓

- {/if} -
- - -
- - - - {#if provider?.hasBearerToken} -

Bearer token saved ✓

- {/if} -
- - -
- - - {#if showWireApi} - - - {/if} - - - {#if showAzure} - - - {/if} - - -
- - {#if isConfigured} - - {/if} -
-
-{/if} - - diff --git a/src/lib/components/settings/CompactionPanel.svelte b/src/lib/components/settings/CompactionPanel.svelte deleted file mode 100644 index b5c45c3..0000000 --- a/src/lib/components/settings/CompactionPanel.svelte +++ /dev/null @@ -1,37 +0,0 @@ - - -

- Compact the conversation to reduce context size while preserving key information. -

- - - diff --git a/src/lib/components/settings/ExtensionsPanel.svelte b/src/lib/components/settings/ExtensionsPanel.svelte deleted file mode 100644 index cbc4e8a..0000000 --- a/src/lib/components/settings/ExtensionsPanel.svelte +++ /dev/null @@ -1,126 +0,0 @@ - - -
-

- Extensions are additional capabilities discovered by the Copilot CLI. Toggle to enable or disable. -

- -
-{#if loading} -
-
-
-
-
-{:else if extensions.length === 0} -

No extensions available. Start a session first.

-{:else} - {#each extensions as ext (ext.name)} -
- - {#if ext.description} -

{ext.description}

- {/if} -
- {/each} -{/if} - - diff --git a/src/lib/components/settings/InstructionsPanel.svelte b/src/lib/components/settings/InstructionsPanel.svelte deleted file mode 100644 index b421a26..0000000 --- a/src/lib/components/settings/InstructionsPanel.svelte +++ /dev/null @@ -1,138 +0,0 @@ - - -{#if instructions.length > 0} -

Instruction files from ~/.copilot/ are automatically applied at session creation. No action needed.

- {#each [...groupBySource(instructions).entries()] as [source, items] (source)} -
-
{items.length}
- {#each items as inst (inst.path)} -
- {inst.name} - {#if inst.applyTo} - {inst.applyTo} - {/if} -
- {/each} -
- {/each} -{:else} -

No instruction files found.

-{/if} -
-

Additional session instructions:

- -
- -
- - diff --git a/src/lib/components/settings/McpServersPanel.svelte b/src/lib/components/settings/McpServersPanel.svelte deleted file mode 100644 index 86e553e..0000000 --- a/src/lib/components/settings/McpServersPanel.svelte +++ /dev/null @@ -1,177 +0,0 @@ - - -

- MCP servers are configured in ~/.copilot/mcp-config.json. Their tools become available to the model automatically. -

- -
-
- GitHub - built-in -
-
api.githubcopilot.com — always active with full access
-
- - -{#if loading} -
-
-
-
-{:else if discoveredMcpServers.length > 0} - {#each discoveredMcpServers as server (server.name)} - {@const sessionTools = getMcpTools(server.name)} - {@const isAuthError = server.error?.includes('HTTP error') || server.error?.includes('401') || server.error?.includes('Streamable HTTP')} - {@const effectiveStatus = isAuthError && server.status === 'failed' ? 'auth_required' : server.status} -
-
- {server.name} - {server.type || 'mcp'} - {#if effectiveStatus && effectiveStatus !== 'not_configured'} - {effectiveStatus === 'auth_required' ? 'needs auth' : effectiveStatus} - {/if} -
-
{server.url || server.command || 'CLI-configured'}
- {#if !isAuthError && server.error} -
{server.error}
- {/if} - {#if sessionTools.length > 0} -
-
Tools exposed to this session
-
- {#each sessionTools as tool (tool.name)} -
{tool.name}
- {/each} -
-

- To test this MCP, ask for one of these capabilities explicitly so the model has a clear reason to call it. -

-
- {:else if effectiveStatus === 'connected'} -

- Connected and available to the model. Tools are managed by the SDK and will be called when relevant. -

- {:else if effectiveStatus === 'failed'} -

- This server is configured but failed to start, so it cannot expose tools to the current session yet. -

- {:else if effectiveStatus === 'auth_required'} -

- This server requires OAuth authentication. Run copilot /mcp in the CLI to authenticate, then start a new chat session. -

- {:else} -

- No tools from this server are visible in the current session yet. Start a fresh chat session after enabling it, then reopen Settings to confirm its tools appear here. -

- {/if} -
- {/each} -{:else} -

No CLI-configured MCP servers found.

-{/if} - - diff --git a/src/lib/components/settings/NotificationsPanel.svelte b/src/lib/components/settings/NotificationsPanel.svelte deleted file mode 100644 index 39d3bc2..0000000 --- a/src/lib/components/settings/NotificationsPanel.svelte +++ /dev/null @@ -1,163 +0,0 @@ - - -{#if notificationStatus === 'loading'} -

Checking notification status…

-{:else if notificationStatus === 'unsupported'} -

Push notifications are not supported in this browser.

-{:else if notificationStatus === 'not-standalone-ios'} -

- To enable notifications on iOS, install this app first: - tap the Share button, then Add to Home Screen. -

-{:else if notificationStatus === 'denied'} -

- Notification permission was blocked. To re-enable, open your browser or device settings and allow notifications for this site. -

-{:else if notificationStatus === 'subscribed'} -

Push notifications are enabled. You'll be notified when responses arrive while the app is in the background.

- -{:else if notificationStatus === 'granted-no-push'} -

Notifications are allowed but push is not set up. Tap below to enable push notifications.

- -{:else} -

Get notified when responses arrive while the app is in the background.

- -{/if} - - diff --git a/src/lib/components/settings/PromptsPanel.svelte b/src/lib/components/settings/PromptsPanel.svelte deleted file mode 100644 index 1275f04..0000000 --- a/src/lib/components/settings/PromptsPanel.svelte +++ /dev/null @@ -1,73 +0,0 @@ - - -

- Prompt templates from ~/.copilot/prompts/. Type /name in the chat input to use one. -

-{#if prompts.length === 0} -

No prompt files found.

-{:else} - {#each [...groupBySource(prompts).entries()] as [source, items] (source)} -
-
{items.length}
- {#each items as prompt (prompt.name)} -
- {prompt.name} - {#if prompt.description} -

{prompt.description}

- {/if} -
- {/each} -
- {/each} -{/if} - - diff --git a/src/lib/components/settings/QuotaPanel.svelte b/src/lib/components/settings/QuotaPanel.svelte deleted file mode 100644 index a844283..0000000 --- a/src/lib/components/settings/QuotaPanel.svelte +++ /dev/null @@ -1,110 +0,0 @@ - - -{#if primaryQuota} -
{primaryQuota.label}
- {#if primaryQuota.snapshot.isUnlimitedEntitlement} -
- Unlimited - {#if primaryQuota.snapshot.usedRequests != null} - · {primaryQuota.snapshot.usedRequests} credits used - {/if} - {#if primaryQuota.snapshot.resetDate} - · Resets {formatResetDate(primaryQuota.snapshot.resetDate)} - {/if} -
- {:else} -
-
-
-
- {#if primaryQuota.snapshot.usedRequests != null && primaryQuota.snapshot.entitlementRequests != null} - {primaryQuota.snapshot.usedRequests} / {primaryQuota.snapshot.entitlementRequests} credits used - {:else} - {quotaPercentUsed.toFixed(1)}% used - {/if} - {#if primaryQuota.snapshot.resetDate} - · Resets {formatResetDate(primaryQuota.snapshot.resetDate)} - {/if} -
- {#if primaryQuota.snapshot.overage != null && primaryQuota.snapshot.overage > 0} -
- ⚠ {primaryQuota.snapshot.overage} overage credits -
- {/if} - {/if} -{:else} -

No quota information available.

-{/if} - - diff --git a/src/lib/components/settings/RemoteSessionPanel.svelte b/src/lib/components/settings/RemoteSessionPanel.svelte deleted file mode 100644 index b1574ce..0000000 --- a/src/lib/components/settings/RemoteSessionPanel.svelte +++ /dev/null @@ -1,123 +0,0 @@ - - -

- Remote sessions publish your conversation to github.com so you can view or steer it from other devices. - This setting applies to new sessions. -

- -
- {#each MODES as mode (mode.value)} - - {/each} -
- -{#if sessionActive && onApplyToSession} - -{/if} - - diff --git a/src/lib/components/settings/SettingsModal.svelte b/src/lib/components/settings/SettingsModal.svelte deleted file mode 100644 index 175bbc0..0000000 --- a/src/lib/components/settings/SettingsModal.svelte +++ /dev/null @@ -1,668 +0,0 @@ - - - - -{#if open} - - - -{/if} - - diff --git a/src/lib/components/settings/SkillsPanel.svelte b/src/lib/components/settings/SkillsPanel.svelte deleted file mode 100644 index 6edf3c2..0000000 --- a/src/lib/components/settings/SkillsPanel.svelte +++ /dev/null @@ -1,96 +0,0 @@ - - -

- Skills are prompt modules discovered by the Copilot CLI. Toggle to enable or disable. The model invokes them automatically when relevant. -

-{#if loading} -
-
-
-
-
-{:else if availableSkills.length === 0} -

No skills available. Start a session first.

-{:else} - {#each availableSkills as skill (skill.name)} -
- - {#if skill.source} - - {/if} - {#if skill.description} -

{skill.description}

- {/if} -
- {/each} -{/if} - - diff --git a/src/lib/components/settings/ToolsPanel.svelte b/src/lib/components/settings/ToolsPanel.svelte deleted file mode 100644 index 0502798..0000000 --- a/src/lib/components/settings/ToolsPanel.svelte +++ /dev/null @@ -1,127 +0,0 @@ - - -{#if loading} -
-
-
-
-
-{:else if tools.length === 0} -

No tools available.

-{:else} - {#each [...groupedTools.entries()] as [server, serverTools] (server)} -
-
{server}
- {#each serverTools as tool (tool.namespacedName ?? tool.name)} -
- - {#if tool.description} -
{tool.description}
- {/if} -
- {/each} -
- {/each} -{/if} - - diff --git a/src/lib/components/shared/IconButton.svelte b/src/lib/components/shared/IconButton.svelte deleted file mode 100644 index f5df956..0000000 --- a/src/lib/components/shared/IconButton.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - - - - diff --git a/src/lib/components/shared/SourceBadge.svelte b/src/lib/components/shared/SourceBadge.svelte deleted file mode 100644 index db3768c..0000000 --- a/src/lib/components/shared/SourceBadge.svelte +++ /dev/null @@ -1,44 +0,0 @@ - - -{labels[source]} - - diff --git a/src/lib/components/shared/Spinner.svelte b/src/lib/components/shared/Spinner.svelte deleted file mode 100644 index d433b47..0000000 --- a/src/lib/components/shared/Spinner.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - - - - diff --git a/src/lib/server/byok/provider-store.ts b/src/lib/server/byok/provider-store.ts deleted file mode 100644 index b8fc716..0000000 --- a/src/lib/server/byok/provider-store.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { readFile, writeFile, rename, mkdir, unlink } from 'node:fs/promises'; -import { join } from 'node:path'; -import { createCipheriv, createDecipheriv, randomBytes, hkdfSync } from 'node:crypto'; -import { config } from '../config.js'; - -/** - * Encrypted storage for BYOK provider configurations. - * - * API keys are encrypted at rest using AES-256-GCM with a key derived - * from SESSION_SECRET via HKDF. Each write uses a fresh random IV. - */ - -const ALGORITHM = 'aes-256-gcm'; -const IV_LENGTH = 12; -const AUTH_TAG_LENGTH = 16; -const HKDF_INFO = 'copilot-byok-provider'; - -export interface ProviderConfig { - type?: 'openai' | 'azure' | 'anthropic'; - baseUrl: string; - apiKey?: string; - bearerToken?: string; - wireApi?: 'completions' | 'responses'; - azure?: { apiVersion?: string }; -} - -interface StoredProviderConfig { - type?: 'openai' | 'azure' | 'anthropic'; - baseUrl: string; - wireApi?: 'completions' | 'responses'; - azure?: { apiVersion?: string }; - encryptedApiKey?: string; - encryptedBearerToken?: string; -} - -function deriveKey(secret: string): Buffer { - return Buffer.from(hkdfSync('sha256', secret, '', HKDF_INFO, 32)); -} - -function encrypt(plaintext: string, secret: string): string { - const key = deriveKey(secret); - const iv = randomBytes(IV_LENGTH); - const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); - const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); - const authTag = cipher.getAuthTag(); - return Buffer.concat([iv, encrypted, authTag]).toString('base64'); -} - -function decrypt(sealed: string, secret: string): string { - const key = deriveKey(secret); - const buf = Buffer.from(sealed, 'base64'); - const iv = buf.subarray(0, IV_LENGTH); - const authTag = buf.subarray(buf.length - AUTH_TAG_LENGTH); - const ciphertext = buf.subarray(IV_LENGTH, buf.length - AUTH_TAG_LENGTH); - const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); - decipher.setAuthTag(authTag); - return decipher.update(ciphertext) + decipher.final('utf8'); -} - -function providerFilePath(userLogin: string): string { - return join(config.settingsStorePath, `${userLogin}.byok.json`); -} - -export async function saveProviderConfig(userLogin: string, provider: ProviderConfig): Promise { - const secret = config.sessionSecret; - const stored: StoredProviderConfig = { - type: provider.type, - baseUrl: provider.baseUrl, - wireApi: provider.wireApi, - azure: provider.azure, - }; - - if (provider.apiKey) { - stored.encryptedApiKey = encrypt(provider.apiKey, secret); - } - if (provider.bearerToken) { - stored.encryptedBearerToken = encrypt(provider.bearerToken, secret); - } - - const dir = config.settingsStorePath; - await mkdir(dir, { recursive: true }); - const filePath = providerFilePath(userLogin); - const tmpPath = filePath + '.tmp.' + randomBytes(4).toString('hex'); - await writeFile(tmpPath, JSON.stringify(stored), { encoding: 'utf8', mode: 0o600 }); - await rename(tmpPath, filePath); -} - -export async function loadProviderConfig(userLogin: string): Promise { - const secret = config.sessionSecret; - try { - const raw = await readFile(providerFilePath(userLogin), 'utf8'); - const stored: StoredProviderConfig = JSON.parse(raw); - const result: ProviderConfig = { - type: stored.type, - baseUrl: stored.baseUrl, - wireApi: stored.wireApi, - azure: stored.azure, - }; - - if (stored.encryptedApiKey) { - result.apiKey = decrypt(stored.encryptedApiKey, secret); - } - if (stored.encryptedBearerToken) { - result.bearerToken = decrypt(stored.encryptedBearerToken, secret); - } - - return result; - } catch (err: unknown) { - if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') { - return null; - } - console.error('[BYOK] Failed to load provider config:', err instanceof Error ? err.message : err); - return null; - } -} - -export async function deleteProviderConfig(userLogin: string): Promise { - try { - await unlink(providerFilePath(userLogin)); - } catch (err: unknown) { - if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.error('[BYOK] Failed to delete provider config:', err instanceof Error ? err.message : err); - } - } -} - -/** Returns a sanitized version of the provider config (no secrets) for client display. */ -export function sanitizeProviderForClient(provider: ProviderConfig): Record { - return { - type: provider.type || 'openai', - baseUrl: provider.baseUrl, - wireApi: provider.wireApi, - hasApiKey: Boolean(provider.apiKey), - hasBearerToken: Boolean(provider.bearerToken), - azure: provider.azure, - }; -} diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts index fc3879a..7b95f94 100644 --- a/src/lib/server/config.ts +++ b/src/lib/server/config.ts @@ -28,24 +28,13 @@ function getConfig() { sessionPoolTtl: parseInt(env('SESSION_POOL_TTL_MS', String(5 * 60 * 1000))), maxSessionsPerUser: parseInt(env('MAX_SESSIONS_PER_USER', '5')), copilotConfigDir: process.env.COPILOT_CONFIG_DIR?.trim().replace(/^~/, homedir()) || undefined, - copilotCwd: process.env.COPILOT_CWD?.trim().replace(/^~/, homedir()) || undefined, chatStatePath: env('CHAT_STATE_PATH', env('NODE_ENV', 'development') !== 'production' ? '.chat-state' : '/data/chat-state'), pushStorePath: env('PUSH_STORE_PATH', env('NODE_ENV', 'development') !== 'production' ? '.push-subscriptions' : '/data/push-subscriptions'), - byokEnabled: process.env.BYOK_ENABLED === 'true', vapid: { publicKey: process.env.VAPID_PUBLIC_KEY?.trim() || '', privateKey: process.env.VAPID_PRIVATE_KEY?.trim() || '', subject: env('VAPID_SUBJECT', 'mailto:admin@example.com'), }, - otelEndpoint: process.env.OTEL_ENDPOINT?.trim() || '', - otelCaptureContent: process.env.OTEL_CAPTURE_CONTENT === 'true', - otelSourceName: env('OTEL_SOURCE_NAME', 'copilot-unleashed'), - enableRemoteSessions: process.env.ENABLE_REMOTE_SESSIONS?.trim().toLowerCase() !== 'false', - // SDK client mode: "empty" (safe, explicit opt-in — default) or "copilot-cli" - // (legacy escape hatch giving the agent CLI-equivalent ambient capabilities). - copilotClientMode: (process.env.COPILOT_CLIENT_MODE?.trim().toLowerCase() === 'copilot-cli' - ? 'copilot-cli' - : 'empty') as 'empty' | 'copilot-cli', }; } diff --git a/src/lib/server/copilot/client.ts b/src/lib/server/copilot/client.ts index b7406be..28db6b3 100644 --- a/src/lib/server/copilot/client.ts +++ b/src/lib/server/copilot/client.ts @@ -1,33 +1,20 @@ import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { CopilotClient, RuntimeConnection } from '@github/copilot-sdk'; -import type { TelemetryConfig } from '@github/copilot-sdk'; -import { config } from '../config.js'; - -function buildTelemetryConfig(): TelemetryConfig | undefined { - if (!config.otelEndpoint) return undefined; - return { - otlpEndpoint: config.otelEndpoint, - captureContent: config.otelCaptureContent, - sourceName: config.otelSourceName, - }; -} +import { CopilotClient } from '@github/copilot-sdk'; export function createCopilotClient(githubToken: string, configDir?: string): CopilotClient { - const telemetry = buildTelemetryConfig(); + const clientEnv: Record = { ...process.env, GH_TOKEN: githubToken }; - // Empty mode requires an explicit persistence location, so always resolve one. - const baseDirectory = configDir || config.copilotConfigDir || join(homedir(), '.copilot'); + // When configDir is set, pass COPILOT_HOME to the CLI subprocess so it + // reads and writes session state from the same directory as the CLI. + if (configDir) { + clientEnv.COPILOT_HOME = configDir; + } return new CopilotClient({ - connection: RuntimeConnection.forStdio(), - gitHubToken: githubToken, - workingDirectory: config.copilotCwd || homedir(), - // "empty" mode disables the CLI's ambient host capabilities by default; - // each session explicitly opts back into the features this app uses. - mode: config.copilotClientMode, - baseDirectory, - ...(telemetry && { telemetry }), - enableRemoteSessions: config.enableRemoteSessions, + githubToken, + env: clientEnv, + // Use home directory as CWD so the CLI subprocess has write access + // (in Docker, WORKDIR /app is root-owned but the process runs as node) + cwd: homedir(), }); } diff --git a/src/lib/server/copilot/session-empty-mode.test.ts b/src/lib/server/copilot/session-empty-mode.test.ts deleted file mode 100644 index 6b90d04..0000000 --- a/src/lib/server/copilot/session-empty-mode.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const configMock = vi.hoisted(() => ({ - copilotConfigDir: '/copilot-config', - copilotClientMode: 'empty' as string, -})); - -vi.mock('@github/copilot-sdk', () => { - class ToolSetMock { - private tools: string[] = []; - addBuiltIn(name: string) { this.tools.push(`builtin:${name}`); return this; } - addMcp(name: string) { this.tools.push(`mcp:${name}`); return this; } - addCustom(name: string) { this.tools.push(`custom:${name}`); return this; } - toArray() { return [...this.tools]; } - } - return { - CopilotClient: vi.fn(), - ToolSet: ToolSetMock, - }; -}); - -vi.mock('../config.js', () => ({ config: configMock })); - -import { buildEmptyModeSessionDefaults } from './session.js'; - -describe('buildEmptyModeSessionDefaults', () => { - beforeEach(() => { - configMock.copilotClientMode = 'empty'; - }); - - it('returns no overrides when the client is not in empty mode', () => { - configMock.copilotClientMode = 'copilot-cli'; - expect(buildEmptyModeSessionDefaults()).toEqual({}); - }); - - it('re-enables features the app relies on under empty mode', () => { - const defaults = buildEmptyModeSessionDefaults(); - - expect(defaults).toMatchObject({ - enableSkills: true, - enableConfigDiscovery: true, - enableHostGitOperations: true, - enableSessionStore: true, - enableOnDemandInstructionDiscovery: true, - mcpOAuthTokenStorage: 'persistent', - embeddingCacheStorage: 'persistent', - skipEmbeddingRetrieval: false, - skipCustomInstructions: false, - coauthorEnabled: true, - }); - }); - - it('grants all built-in, MCP, and custom tools via availableTools', () => { - const defaults = buildEmptyModeSessionDefaults(); - expect(defaults.availableTools).toEqual(['builtin:*', 'mcp:*', 'custom:*']); - }); - - it('does not re-enable file hooks or telemetry', () => { - const defaults = buildEmptyModeSessionDefaults(); - expect(defaults).not.toHaveProperty('enableFileHooks'); - expect(defaults).not.toHaveProperty('enableSessionTelemetry'); - }); -}); diff --git a/src/lib/server/copilot/session-store-db.test.ts b/src/lib/server/copilot/session-store-db.test.ts deleted file mode 100644 index 5f95739..0000000 --- a/src/lib/server/copilot/session-store-db.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { join } from 'node:path'; - -const existsSyncMock = vi.hoisted(() => vi.fn()); - -vi.mock('node:fs', () => ({ - existsSync: existsSyncMock, - default: { existsSync: existsSyncMock }, -})); - -vi.mock('../config.js', () => ({ - config: { - copilotConfigDir: join('/', 'mock-copilot'), - }, -})); - -import { - loadSessionTurns, - loadSessionMetadata, - getSessionStoreDbPath, -} from './session-store-db.js'; - -/** - * Helper: check whether a real session-store.db exists and node:sqlite works. - * Returns { dbPath, DatabaseSync } or null if unavailable. - */ -async function getRealDb(): Promise<{ dbPath: string; DatabaseSync: typeof import('node:sqlite').DatabaseSync } | null> { - const { existsSync: realExists } = await vi.importActual('node:fs'); - const { homedir } = await vi.importActual('node:os'); - const dbPath = join(homedir(), '.copilot', 'session-store.db'); - if (!realExists(dbPath)) return null; - - try { - // Rolldown (Vite 8.1+) refuses to bundle Node built-ins referenced via - // import() in the test pipeline — load node:sqlite through createRequire - // instead, mirroring what session-store-db.ts itself does. - const { createRequire } = await vi.importActual('node:module'); - const esmRequire = createRequire(import.meta.url); - const { DatabaseSync } = esmRequire('node:sqlite') as typeof import('node:sqlite'); - return { dbPath, DatabaseSync }; - } catch { - return null; - } -} - -beforeEach(() => { - existsSyncMock.mockReset(); -}); - -describe('getSessionStoreDbPath', () => { - it('returns path under copilotConfigDir', () => { - const expected = join('/', 'mock-copilot', 'session-store.db'); - expect(getSessionStoreDbPath()).toBe(expected); - }); -}); - -describe('loadSessionTurns', () => { - it('returns empty array when database does not exist', () => { - existsSyncMock.mockReturnValue(false); - const result = loadSessionTurns('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); - expect(result).toEqual([]); - }); - - it('returns empty array for unknown session (mock path, no DB file)', () => { - existsSyncMock.mockReturnValue(true); - const result = loadSessionTurns('00000000-0000-0000-0000-000000000000'); - expect(result).toEqual([]); - }); - - it('returns correctly shaped messages from a real session-store.db', async () => { - const env = await getRealDb(); - if (!env) return; // skip in CI - - // Point existsSync at the real DB path - existsSyncMock.mockReturnValue(true); - - const db = new env.DatabaseSync(env.dbPath, { open: true, readOnly: true }); - const row = db.prepare( - 'SELECT session_id FROM turns WHERE user_message IS NOT NULL LIMIT 1', - ).get() as { session_id: string } | undefined; - db.close(); - - if (!row) return; - - // Override getSessionStoreDbPath by making existsSync return true - // and relying on the real node:sqlite + the default homedir fallback. - // We need to unmock config to use the real path — instead, just call - // loadSessionTurns which will hit the mock path (/mock-copilot/...). - // Since that file doesn't exist, we test via the real DB directly: - const { DatabaseSync } = env; - const realDb = new DatabaseSync(env.dbPath, { open: true, readOnly: true }); - const rows = realDb.prepare( - `SELECT turn_index, user_message, assistant_response, timestamp - FROM turns WHERE session_id = ? ORDER BY turn_index ASC`, - ).all(row.session_id) as Array<{ turn_index: number; user_message: string | null; assistant_response: string | null; timestamp: string }>; - realDb.close(); - - expect(rows.length).toBeGreaterThan(0); - - // Verify the same logic our function uses produces valid output - const messages: Array<{ type: string; content: string; timestamp: number }> = []; - for (const r of rows) { - const ts = r.timestamp ? new Date(r.timestamp).getTime() : Date.now(); - if (r.user_message) messages.push({ type: 'user', content: r.user_message, timestamp: ts }); - if (r.assistant_response) messages.push({ type: 'assistant', content: r.assistant_response, timestamp: ts + 1 }); - } - - expect(messages.length).toBeGreaterThan(0); - for (const msg of messages) { - expect(['user', 'assistant']).toContain(msg.type); - expect(typeof msg.content).toBe('string'); - expect(msg.content.length).toBeGreaterThan(0); - expect(typeof msg.timestamp).toBe('number'); - expect(msg.timestamp).toBeGreaterThan(0); - } - }); - - it('user message comes before assistant message within the same turn', async () => { - const env = await getRealDb(); - if (!env) return; - - const db = new env.DatabaseSync(env.dbPath, { open: true, readOnly: true }); - const row = db.prepare( - `SELECT turn_index, user_message, assistant_response, timestamp FROM turns - WHERE user_message IS NOT NULL AND assistant_response IS NOT NULL - LIMIT 1`, - ).get() as { turn_index: number; user_message: string; assistant_response: string; timestamp: string } | undefined; - db.close(); - - if (!row) return; - - const ts = new Date(row.timestamp).getTime(); - - // Same logic as loadSessionTurns - const userMsg = { type: 'user' as const, content: row.user_message, timestamp: ts }; - const asstMsg = { type: 'assistant' as const, content: row.assistant_response, timestamp: ts + 1 }; - - expect(userMsg.type).toBe('user'); - expect(asstMsg.type).toBe('assistant'); - expect(asstMsg.timestamp).toBe(userMsg.timestamp + 1); - }); -}); - -describe('loadSessionMetadata', () => { - it('returns null when database does not exist', () => { - existsSyncMock.mockReturnValue(false); - const result = loadSessionMetadata('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); - expect(result).toBeNull(); - }); - - it('returns null for unknown session (mock path)', () => { - existsSyncMock.mockReturnValue(true); - const result = loadSessionMetadata('00000000-0000-0000-0000-000000000000'); - expect(result).toBeNull(); - }); - - it('returns metadata with correct shape from a real session-store.db', async () => { - const env = await getRealDb(); - if (!env) return; - - const db = new env.DatabaseSync(env.dbPath, { open: true, readOnly: true }); - const row = db.prepare( - "SELECT id, summary, repository, branch FROM sessions WHERE summary IS NOT NULL AND summary != '' LIMIT 1", - ).get() as { id: string; summary: string; repository: string | null; branch: string | null } | undefined; - db.close(); - - if (!row) return; - - expect(row).toHaveProperty('summary'); - expect(row).toHaveProperty('repository'); - expect(row).toHaveProperty('branch'); - expect(typeof row.summary).toBe('string'); - expect(row.summary.length).toBeGreaterThan(0); - }); -}); diff --git a/src/lib/server/copilot/session-store-db.ts b/src/lib/server/copilot/session-store-db.ts deleted file mode 100644 index 8fd215f..0000000 --- a/src/lib/server/copilot/session-store-db.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { join } from 'node:path'; -import { homedir } from 'node:os'; -import { existsSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { config } from '../config.js'; - -const esmRequire = createRequire(import.meta.url); - -interface SessionTurnRow { - turn_index: number; - user_message: string | null; - assistant_response: string | null; - timestamp: string; -} - -interface SessionMetadataRow { - summary: string | null; - repository: string | null; - branch: string | null; -} - -export interface RestoredMessage { - type: 'user' | 'assistant'; - content: string; - timestamp: number; -} - -interface SqliteStatement { - all(...params: unknown[]): unknown[]; - get(...params: unknown[]): unknown; -} - -interface SqliteDatabase { - prepare(sql: string): SqliteStatement; - close(): void; -} - -interface SqliteConstructor { - new (path: string, options?: Record): SqliteDatabase; -} - -/** Resolve the path to the CLI's session-store.db */ -export function getSessionStoreDbPath(): string { - const base = config.copilotConfigDir || join(homedir(), '.copilot'); - return join(base, 'session-store.db'); -} - -/** Try to load DatabaseSync from node:sqlite. Returns null on older runtimes. */ -function getDatabaseSync(): SqliteConstructor | null { - try { - // node:sqlite is experimental in Node 24 — dynamic require via createRequire - // because bare require() doesn't exist in ESM context. - const mod = esmRequire('node:sqlite') as { DatabaseSync: SqliteConstructor }; - return mod.DatabaseSync; - } catch { - return null; - } -} - -/** - * Load conversation turns for a session from the CLI's session-store.db. - * Returns messages in the same format the frontend expects for cold_resume: - * `{ type: 'user'|'assistant', content: string, timestamp: number }` - * - * Uses node:sqlite (DatabaseSync) in read-only mode. Returns an empty array - * if the database doesn't exist or the session has no turns. - */ -export function loadSessionTurns(sessionId: string): RestoredMessage[] { - const dbPath = getSessionStoreDbPath(); - - if (!existsSync(dbPath)) { - return []; - } - - const DatabaseSync = getDatabaseSync(); - if (!DatabaseSync) { - console.warn('[SESSION-STORE-DB] node:sqlite not available — skipping turn loading'); - return []; - } - - let db: SqliteDatabase | null = null; - try { - db = new DatabaseSync(dbPath, { open: true, readOnly: true }); - - const rows = db - .prepare( - `SELECT turn_index, user_message, assistant_response, timestamp - FROM turns - WHERE session_id = ? - ORDER BY turn_index ASC - LIMIT 500`, - ) - .all(sessionId) as SessionTurnRow[]; - - const messages: RestoredMessage[] = []; - - for (const row of rows) { - const ts = row.timestamp ? new Date(row.timestamp).getTime() : Date.now(); - - if (row.user_message) { - messages.push({ type: 'user', content: row.user_message, timestamp: ts }); - } - if (row.assistant_response) { - messages.push({ type: 'assistant', content: row.assistant_response, timestamp: ts + 1 }); - } - } - - return messages; - } catch (err) { - console.warn( - '[SESSION-STORE-DB] Failed to read turns:', - err instanceof Error ? err.message : err, - ); - return []; - } finally { - try { db?.close(); } catch { /* ignore */ } - } -} - -/** - * Load session metadata (summary, repo, branch) from session-store.db. - * Returns null if the session isn't found. - */ -export function loadSessionMetadata( - sessionId: string, -): { summary: string | null; repository: string | null; branch: string | null } | null { - const dbPath = getSessionStoreDbPath(); - - if (!existsSync(dbPath)) return null; - - const DatabaseSync = getDatabaseSync(); - if (!DatabaseSync) return null; - - let db: SqliteDatabase | null = null; - try { - db = new DatabaseSync(dbPath, { open: true, readOnly: true }); - - const row = db - .prepare('SELECT summary, repository, branch FROM sessions WHERE id = ?') - .get(sessionId) as SessionMetadataRow | undefined; - - return row ?? null; - } catch { - return null; - } finally { - try { db?.close(); } catch { /* ignore */ } - } -} diff --git a/src/lib/server/copilot/session.test.ts b/src/lib/server/copilot/session.test.ts index 7c3565d..8c53fd1 100644 --- a/src/lib/server/copilot/session.test.ts +++ b/src/lib/server/copilot/session.test.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; -import { createHash } from 'node:crypto'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; + +const { defineToolMock } = vi.hoisted(() => ({ + defineToolMock: vi.fn((name: string, options: Record) => ({ name, ...options })), +})); vi.mock('@github/copilot-sdk', () => ({ CopilotClient: vi.fn(), + defineTool: defineToolMock, })); vi.mock('../config.js', () => ({ @@ -14,13 +15,22 @@ vi.mock('../config.js', () => ({ }, })); -import { createCopilotSession, getAvailableModels, buildSessionHooks, buildSessionMcpServers } from './session.js'; +import { createCopilotSession, getAvailableModels, buildSessionHooks } from './session.js'; interface ClientMock { createSession: ReturnType; listModels: ReturnType; } +interface DefinedTool { + name: string; + description: string; + parameters: { + parse: (input: unknown) => unknown; + }; + handler: (args: unknown) => Promise; +} + function createClientMock(): ClientMock { return { createSession: vi.fn(async (sessionConfig: unknown) => ({ sessionConfig })), @@ -33,9 +43,16 @@ function getSessionConfig(client: ClientMock): Record { return sessionConfig; } +function getCreatedTool(client: ClientMock): DefinedTool { + const sessionConfig = getSessionConfig(client); + const tools = sessionConfig.tools as DefinedTool[]; + return tools[0]; +} + const fetchMock = vi.fn(); beforeEach(() => { + defineToolMock.mockClear(); fetchMock.mockReset(); fetchMock.mockResolvedValue({ ok: true, @@ -61,11 +78,10 @@ describe('createCopilotSession', () => { const sessionConfig = getSessionConfig(client); expect(sessionConfig).toMatchObject({ clientName: 'copilot-unleashed', + model: 'gpt-4.1', streaming: true, - configDirectory: '/copilot-config', + configDir: '/copilot-config', }); - // No hardcoded model — the SDK picks its default when none is given - expect(sessionConfig).not.toHaveProperty('model'); const mcpServers = sessionConfig.mcpServers as Record>; expect(mcpServers.github).toEqual({ @@ -117,6 +133,13 @@ describe('createCopilotSession', () => { bufferExhaustionThreshold: 0.4, }, configDir: '/custom-config', + mcpServers: [{ + name: 'public-api', + url: 'https://api.example.com:8443/stream/%E2%9C%93', + type: 'http', + headers: { 'X-Test': 'true' }, + tools: [], + }], }); expect(getSessionConfig(client)).toMatchObject({ @@ -125,7 +148,7 @@ describe('createCopilotSession', () => { excludedTools: ['bash'], availableTools: ['read'], onUserInputRequest, - configDirectory: '/custom-config', + configDir: '/custom-config', systemMessage: { mode: 'append', content: 'Stay concise.', @@ -136,6 +159,204 @@ describe('createCopilotSession', () => { bufferExhaustionThreshold: 0.4, }, }); + + const mcpServers = getSessionConfig(client).mcpServers as Record>; + expect(mcpServers['public-api']).toEqual({ + type: 'http', + url: 'https://api.example.com:8443/stream/%E2%9C%93', + headers: { 'X-Test': 'true' }, + tools: ['*'], + }); + }); + + it('builds POST custom tools with zod parameter parsing and JSON fetch bodies', async () => { + const client = createClientMock(); + + await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + customTools: [{ + name: 'webhook-tool', + description: 'Calls a webhook', + webhookUrl: 'https://tools.example.com/hooks/%E2%9C%93', + method: 'POST', + headers: { Authorization: 'Bearer abc' }, + parameters: { + query: { type: 'string', description: 'Search query' }, + count: { type: 'number', description: 'Result count' }, + enabled: { type: 'boolean', description: 'Feature toggle' }, + }, + }], + }); + + expect(defineToolMock).toHaveBeenCalledTimes(1); + const tool = getCreatedTool(client); + + expect(tool.parameters.parse({ query: 'copilot', count: 2, enabled: true })).toEqual({ + query: 'copilot', + count: 2, + enabled: true, + }); + + const result = await tool.handler({ query: 'copilot', count: 2, enabled: true }); + + expect(result).toEqual({ ok: true }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://tools.example.com/hooks/%E2%9C%93', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer abc', + }, + body: JSON.stringify({ query: 'copilot', count: 2, enabled: true }), + }), + ); + }); + + it('omits the request body for GET custom tools', async () => { + const client = createClientMock(); + + await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + customTools: [{ + name: 'get-tool', + description: 'Fetches without a body', + webhookUrl: 'https://tools.example.com/read', + method: 'GET', + headers: {}, + parameters: {}, + }], + }); + + const tool = getCreatedTool(client); + await tool.handler({ ignored: true }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://tools.example.com/read', + expect.objectContaining({ + method: 'GET', + body: undefined, + }), + ); + }); + + it.each([ + 'https://127.0.0.1/hook', + 'https://10.0.0.8/hook', + 'https://172.16.5.10/hook', + 'https://172.31.255.1/hook', + 'https://192.168.1.5/hook', + 'https://169.254.2.3/hook', + 'https://0.0.0.0/hook', + 'https://localhost/hook', + 'https://sub.localhost/hook', + 'https://[::1]/hook', + 'https://[fc00::1]/hook', + 'https://[fe80::1]/hook', + ])('rejects blocked tool URLs: %s', async (webhookUrl) => { + const client = createClientMock(); + + await expect( + createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + customTools: [{ + name: 'private-tool', + description: 'Should fail', + webhookUrl, + method: 'POST', + headers: {}, + parameters: {}, + }], + }), + ).rejects.toThrow('internal network URLs are not allowed'); + }); + + it('rejects HTTP, invalid, and credential-bearing tool URLs', async () => { + const client = createClientMock(); + + await expect( + createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + customTools: [{ + name: 'http-tool', + description: 'Should fail', + webhookUrl: 'http://example.com/hook', + method: 'GET', + headers: {}, + parameters: {}, + }], + }), + ).rejects.toThrow('HTTPS required'); + + await expect( + createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + customTools: [{ + name: 'bad-tool', + description: 'Should fail', + webhookUrl: 'not-a-url', + method: 'GET', + headers: {}, + parameters: {}, + }], + }), + ).rejects.toThrow('invalid URL'); + + await expect( + createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + customTools: [{ + name: 'auth-tool', + description: 'Should fail', + webhookUrl: 'https://user:pass@example.com/hook', + method: 'GET', + headers: {}, + parameters: {}, + }], + }), + ).rejects.toThrow('auth credentials in URLs are not allowed'); + }); + + it.each([ + 'https://127.0.0.1/stream', + 'https://localhost/stream', + 'https://[::1]/stream', + ])('rejects blocked MCP server URLs: %s', async (url) => { + const client = createClientMock(); + + await expect( + createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + mcpServers: [{ + name: 'private-mcp', + url, + type: 'sse', + headers: {}, + tools: ['search'], + }], + }), + ).rejects.toThrow('internal network URLs are not allowed'); + }); + + it('rejects non-HTTPS and invalid MCP server URLs', async () => { + const client = createClientMock(); + + await expect( + createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + mcpServers: [{ + name: 'http-mcp', + url: 'http://public.example.com/stream', + type: 'http', + headers: {}, + tools: ['search'], + }], + }), + ).rejects.toThrow('HTTPS required'); + + await expect( + createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + mcpServers: [{ + name: 'bad-mcp', + url: '%%%bad-url', + type: 'http', + headers: {}, + tools: ['search'], + }], + }), + ).rejects.toThrow('invalid URL'); }); it('passes skillDirectories and disabledSkills to the SDK session config', async () => { @@ -179,6 +400,50 @@ describe('createCopilotSession', () => { expect(config.customAgents).toEqual(customAgents); }); + it('supports SSE-type MCP servers alongside HTTP servers', async () => { + const client = createClientMock(); + + await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + mcpServers: [ + { + name: 'http-server', + url: 'https://api.example.com/mcp', + type: 'http', + headers: { 'X-Api-Key': 'key1' }, + tools: ['search'], + }, + { + name: 'sse-server', + url: 'https://stream.example.com/events', + type: 'sse', + headers: { Authorization: 'Bearer tok' }, + tools: [], + }, + ], + }); + + const mcpServers = getSessionConfig(client).mcpServers as Record>; + + // GitHub server always present + expect(mcpServers.github).toBeDefined(); + + // HTTP server preserved as-is + expect(mcpServers['http-server']).toEqual({ + type: 'http', + url: 'https://api.example.com/mcp', + headers: { 'X-Api-Key': 'key1' }, + tools: ['search'], + }); + + // SSE server with empty tools defaults to wildcard + expect(mcpServers['sse-server']).toEqual({ + type: 'sse', + url: 'https://stream.example.com/events', + headers: { Authorization: 'Bearer tok' }, + tools: ['*'], + }); + }); + it('always includes GitHub MCP server with the provided token', async () => { const client = createClientMock(); @@ -193,222 +458,56 @@ describe('createCopilotSession', () => { }); }); - it('merges stdio MCP servers from mcp-config.json into the session config', async () => { - const dir = await mkdtemp(join(tmpdir(), 'copilot-mcp-test-')); - await writeFile(join(dir, 'mcp-config.json'), JSON.stringify({ - mcpServers: { - 'awesome-copilot': { - type: 'stdio', - command: 'docker', - args: ['run', '-i', '--rm', 'ghcr.io/microsoft/mcp-dotnet-samples/awesome-copilot:latest'], - }, - }, - })); - - try { - const mcpServers: Awaited> = - await buildSessionMcpServers('gh-token', dir); - expect(mcpServers['awesome-copilot']).toEqual({ - type: 'stdio', - command: 'docker', - args: ['run', '-i', '--rm', 'ghcr.io/microsoft/mcp-dotnet-samples/awesome-copilot:latest'], - tools: ['*'], - }); - expect(mcpServers.github).toBeDefined(); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - it('only includes GitHub MCP server when no config file exists', async () => { + it('passes timeout to MCP server config when specified', async () => { const client = createClientMock(); - await createCopilotSession(client as unknown as Parameters[0], 'gh-token'); + await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + mcpServers: [ + { + name: 'with-timeout', + url: 'https://api.example.com/mcp', + type: 'http', + headers: {}, + tools: ['search'], + timeout: 60000, + }, + { + name: 'without-timeout', + url: 'https://api2.example.com/mcp', + type: 'http', + headers: {}, + tools: [], + }, + ], + }); const mcpServers = getSessionConfig(client).mcpServers as Record>; - expect(Object.keys(mcpServers)).toEqual(['github']); - }); - - it('injects OAuth token from CLI token store for HTTP MCP servers without Authorization', async () => { - const dir = await mkdtemp(join(tmpdir(), 'copilot-mcp-oauth-')); - const serverUrl = 'https://agent365.svc.cloud.microsoft/agents/tenants/test/servers/mcp_MailTools'; - const hash = createHash('sha256').update(serverUrl).digest('hex'); - const oauthDir = join(dir, 'mcp-oauth-config'); - await mkdir(oauthDir, { recursive: true }); - await writeFile(join(dir, 'mcp-config.json'), JSON.stringify({ - mcpServers: { - 'm365-mail': { type: 'http', url: serverUrl, headers: {} }, - }, - })); - - await writeFile(join(oauthDir, `${hash}.tokens.json`), JSON.stringify({ - accessToken: 'test-m365-token', - refreshToken: 'test-refresh', - expiresAt: Math.floor(Date.now() / 1000) + 3600, - scope: 'https://agent365.svc.cloud.microsoft/.default', - })); - - try { - const mcpServers = await buildSessionMcpServers('gh-token', dir); - const mailServer = mcpServers['m365-mail'] as { headers?: Record }; - expect(mailServer.headers?.Authorization).toBe('Bearer test-m365-token'); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - it('does not inject OAuth token when server already has an Authorization header', async () => { - const dir = await mkdtemp(join(tmpdir(), 'copilot-mcp-oauth-')); - const serverUrl = 'https://example.com/mcp'; - const hash = createHash('sha256').update(serverUrl).digest('hex'); - const oauthDir = join(dir, 'mcp-oauth-config'); - await mkdir(oauthDir, { recursive: true }); - - await writeFile(join(dir, 'mcp-config.json'), JSON.stringify({ - mcpServers: { - 'custom-server': { type: 'http', url: serverUrl, headers: { Authorization: 'Bearer static-token' } }, - }, - })); - - await writeFile(join(oauthDir, `${hash}.tokens.json`), JSON.stringify({ - accessToken: 'should-not-be-used', - refreshToken: 'r', - expiresAt: Math.floor(Date.now() / 1000) + 3600, - scope: 'test', - })); - - try { - const mcpServers = await buildSessionMcpServers('gh-token', dir); - const server = mcpServers['custom-server'] as { headers?: Record }; - expect(server.headers?.Authorization).toBe('Bearer static-token'); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - it('warns and skips injection when OAuth token is expired', async () => { - const dir = await mkdtemp(join(tmpdir(), 'copilot-mcp-oauth-')); - const serverUrl = 'https://agent365.svc.cloud.microsoft/agents/tenants/test/servers/mcp_CalendarTools'; - const hash = createHash('sha256').update(serverUrl).digest('hex'); - const oauthDir = join(dir, 'mcp-oauth-config'); - await mkdir(oauthDir, { recursive: true }); + expect(mcpServers['with-timeout']).toEqual({ + type: 'http', + url: 'https://api.example.com/mcp', + headers: {}, + tools: ['search'], + timeout: 60000, + }); - await writeFile(join(dir, 'mcp-config.json'), JSON.stringify({ - mcpServers: { - 'm365-calendar': { type: 'http', url: serverUrl, headers: {} }, - }, - })); - - // Expired token with no config file for refresh - await writeFile(join(oauthDir, `${hash}.tokens.json`), JSON.stringify({ - accessToken: 'expired-token', - refreshToken: 'r', - expiresAt: Math.floor(Date.now() / 1000) - 60, - scope: 'test', - })); - - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); - - try { - const mcpServers = await buildSessionMcpServers('gh-token', dir); - const server = mcpServers['m365-calendar'] as { headers?: Record }; - expect(server.headers?.Authorization).toBeUndefined(); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('expired'), - ); - } finally { - warnSpy.mockRestore(); - await rm(dir, { recursive: true, force: true }); - } + expect(mcpServers['without-timeout']).toEqual({ + type: 'http', + url: 'https://api2.example.com/mcp', + headers: {}, + tools: ['*'], + }); }); - it('refreshes an expired OAuth token using the refresh_token grant', async () => { - const dir = await mkdtemp(join(tmpdir(), 'copilot-mcp-oauth-')); - const serverUrl = 'https://agent365.svc.cloud.microsoft/agents/tenants/test/servers/mcp_MailTools'; - const hash = createHash('sha256').update(serverUrl).digest('hex'); - const oauthDir = join(dir, 'mcp-oauth-config'); - await mkdir(oauthDir, { recursive: true }); + it('omits user MCP servers when none are provided', async () => { + const client = createClientMock(); - await writeFile(join(dir, 'mcp-config.json'), JSON.stringify({ - mcpServers: { - 'm365-mail': { type: 'http', url: serverUrl, headers: {} }, - }, - })); - - // Expired token - await writeFile(join(oauthDir, `${hash}.tokens.json`), JSON.stringify({ - accessToken: 'expired-token', - refreshToken: 'valid-refresh-token', - expiresAt: Math.floor(Date.now() / 1000) - 60, - scope: 'https://agent365.svc.cloud.microsoft/.default', - })); - - // OAuth config with authorization server details - await writeFile(join(oauthDir, `${hash}.json`), JSON.stringify({ - serverUrl, - authorizationServerUrl: 'https://login.microsoftonline.com/organizations/v2.0', - clientId: 'test-client-id', - redirectUri: 'http://127.0.0.1:12345/', - resourceUrl: serverUrl, - issuedAt: 0, - isStatic: false, - })); - - // Mock the token endpoint response - fetchMock.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ - access_token: 'fresh-access-token', - refresh_token: 'fresh-refresh-token', - expires_in: 3600, - scope: 'https://agent365.svc.cloud.microsoft/.default', - }), + await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { + mcpServers: [], }); - try { - const mcpServers = await buildSessionMcpServers('gh-token', dir); - const server = mcpServers['m365-mail'] as { headers?: Record }; - expect(server.headers?.Authorization).toBe('Bearer fresh-access-token'); - - // Verify fetch was called with correct params - expect(fetchMock).toHaveBeenCalledWith( - 'https://login.microsoftonline.com/organizations/oauth2/v2.0/token', - expect.objectContaining({ method: 'POST' }), - ); - - // Verify tokens were written back to disk - const { readFile: rf } = await import('node:fs/promises'); - const updated = JSON.parse(await rf(join(oauthDir, `${hash}.tokens.json`), 'utf8')); - expect(updated.accessToken).toBe('fresh-access-token'); - expect(updated.refreshToken).toBe('fresh-refresh-token'); - } finally { - await rm(dir, { recursive: true, force: true }); - } - }); - - it('gracefully handles missing OAuth token files', async () => { - const dir = await mkdtemp(join(tmpdir(), 'copilot-mcp-oauth-')); - - await writeFile(join(dir, 'mcp-config.json'), JSON.stringify({ - mcpServers: { - 'm365-teams': { - type: 'http', - url: 'https://agent365.svc.cloud.microsoft/agents/tenants/test/servers/mcp_TeamsServer', - headers: {}, - }, - }, - })); - - try { - const mcpServers = await buildSessionMcpServers('gh-token', dir); - const server = mcpServers['m365-teams'] as { headers?: Record }; - expect(server.headers?.Authorization).toBeUndefined(); - expect(mcpServers.github).toBeDefined(); - } finally { - await rm(dir, { recursive: true, force: true }); - } + const mcpServers = getSessionConfig(client).mcpServers as Record>; + expect(Object.keys(mcpServers)).toEqual(['github']); }); it('wires session hooks when onHookEvent callback is provided', async () => { @@ -438,36 +537,6 @@ describe('createCopilotSession', () => { expect(config.hooks).toBeUndefined(); }); - it('threads remoteSession=on into the SDK session config', async () => { - const client = createClientMock(); - - await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { - remoteSession: 'on', - }); - - expect(getSessionConfig(client).remoteSession).toBe('on'); - }); - - it('threads remoteSession=export into the SDK session config', async () => { - const client = createClientMock(); - - await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { - remoteSession: 'export', - }); - - expect(getSessionConfig(client).remoteSession).toBe('export'); - }); - - it('omits remoteSession when set to "off" (local-only)', async () => { - const client = createClientMock(); - - await createCopilotSession(client as unknown as Parameters[0], 'gh-token', { - remoteSession: 'off', - }); - - expect(getSessionConfig(client).remoteSession).toBeUndefined(); - }); - }); describe('buildSessionHooks', () => { @@ -476,7 +545,7 @@ describe('buildSessionHooks', () => { const hooks = buildSessionHooks(callback); hooks!.onPreToolUse!( - { toolName: 'bash', toolArgs: { command: 'ls' }, timestamp: new Date(1), sessionId: 's1', workingDirectory: '/tmp' }, + { toolName: 'bash', toolArgs: { command: 'ls' }, timestamp: 1, cwd: '/tmp' }, { sessionId: 's1' }, ); @@ -492,7 +561,7 @@ describe('buildSessionHooks', () => { const hooks = buildSessionHooks(callback); hooks!.onPostToolUse!( - { toolName: 'read', toolArgs: { path: '/file' }, toolResult: { content: 'ok' } as any, timestamp: new Date(1), sessionId: 's1', workingDirectory: '/tmp' }, + { toolName: 'read', toolArgs: { path: '/file' }, toolResult: { content: 'ok' } as any, timestamp: 1, cwd: '/tmp' }, { sessionId: 's1' }, ); @@ -508,7 +577,7 @@ describe('buildSessionHooks', () => { const hooks = buildSessionHooks(callback); hooks!.onSessionStart!( - { source: 'new', timestamp: new Date(1), sessionId: 's1', workingDirectory: '/tmp' }, + { source: 'new', timestamp: 1, cwd: '/tmp' }, { sessionId: 's1' }, ); @@ -523,7 +592,7 @@ describe('buildSessionHooks', () => { const hooks = buildSessionHooks(callback); hooks!.onSessionEnd!( - { reason: 'complete', timestamp: new Date(1), sessionId: 's1', workingDirectory: '/tmp' }, + { reason: 'complete', timestamp: 1, cwd: '/tmp' }, { sessionId: 's1' }, ); @@ -538,7 +607,7 @@ describe('buildSessionHooks', () => { const hooks = buildSessionHooks(callback); hooks!.onErrorOccurred!( - { error: 'timeout', errorContext: 'tool_execution', recoverable: true, timestamp: new Date(1), sessionId: 's1', workingDirectory: '/tmp' }, + { error: 'timeout', errorContext: 'tool_execution', recoverable: true, timestamp: 1, cwd: '/tmp' }, { sessionId: 's1' }, ); diff --git a/src/lib/server/copilot/session.ts b/src/lib/server/copilot/session.ts index c451342..1c898e5 100644 --- a/src/lib/server/copilot/session.ts +++ b/src/lib/server/copilot/session.ts @@ -1,13 +1,19 @@ -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { readFile, writeFile, rename } from 'node:fs/promises'; -import { createHash, randomUUID } from 'node:crypto'; -import { CopilotClient, ToolSet } from '@github/copilot-sdk'; -import type { SessionConfig, SystemMessageSection, SectionOverride, MCPServerConfig, ModelCapabilitiesOverride, RemoteSessionMode, CloudSessionOptions } from '@github/copilot-sdk'; +import { CopilotClient, defineTool } from '@github/copilot-sdk'; +import type { SessionConfig } from '@github/copilot-sdk'; export type HookEventCallback = (message: Record) => void; import { isIP } from 'node:net'; import { config } from '../config.js'; +import { z } from 'zod'; + +export interface CustomToolDefinition { + name: string; + description: string; + webhookUrl: string; + method: 'GET' | 'POST'; + headers: Record; + parameters: Record; +} type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh'; @@ -17,16 +23,27 @@ export interface InfiniteSessionsConfig { bufferExhaustionThreshold?: number; } +export interface McpServerInput { + name: string; + url: string; + type: 'http' | 'sse'; + headers: Record; + tools: string[]; + timeout?: number; +} + export interface CreateSessionOptions { model?: string; reasoningEffort?: ReasoningEffort; customInstructions?: string; excludedTools?: string[]; availableTools?: string[]; + customTools?: CustomToolDefinition[]; infiniteSessions?: InfiniteSessionsConfig; onUserInputRequest?: SessionConfig['onUserInputRequest']; permissionMode?: 'approve_all' | 'prompt'; onPermissionRequest?: SessionConfig['onPermissionRequest']; + mcpServers?: McpServerInput[]; configDir?: string; skillDirectories?: string[]; disabledSkills?: string[]; @@ -37,17 +54,21 @@ export interface CreateSessionOptions { tools?: string[]; prompt: string; }>; - agent?: string; - onEvent?: (event: any) => void; onHookEvent?: HookEventCallback; - systemPromptSections?: Partial>; - modelCapabilities?: ModelCapabilitiesOverride; - enableConfigDiscovery?: boolean; - provider?: SessionConfig['provider']; - onElicitationRequest?: SessionConfig['onElicitationRequest']; - remoteSession?: RemoteSessionMode; - /** Create the session on GitHub's cloud agent infrastructure instead of locally */ - cloud?: CloudSessionOptions; +} + +function buildZodSchema(params: Record): z.ZodObject> { + const shape: Record = {}; + for (const [name, param] of Object.entries(params)) { + let field: z.ZodTypeAny; + switch (param.type) { + case 'number': field = z.number(); break; + case 'boolean': field = z.boolean(); break; + default: field = z.string(); break; + } + shape[name] = field.describe(param.description); + } + return z.object(shape); } function isPrivateIpv4(hostname: string): boolean { @@ -104,15 +125,7 @@ function isBlockedHostname(hostname: string): boolean { } } -function safeServerLabel(url: string): string { - try { - return new URL(url).hostname; - } catch { - return '[invalid-url]'; - } -} - -function validateOutboundUrl(kind: 'Tool' | 'MCP server' | 'MCP token endpoint', name: string, rawUrl: string): void { +function validateOutboundUrl(kind: 'Tool' | 'MCP server', name: string, rawUrl: string): void { let url: URL; try { url = new URL(rawUrl); @@ -133,306 +146,60 @@ function validateOutboundUrl(kind: 'Tool' | 'MCP server' | 'MCP token endpoint', } } -function validateMcpServerUrl(name: string, serverUrl: string): void { - validateOutboundUrl('MCP server', name, serverUrl); -} - -function normalizeStringRecord(value: unknown): Record | undefined { - if (!value || typeof value !== 'object') return undefined; - const entries = Object.entries(value as Record) - .filter(([, v]) => typeof v === 'string') - .map(([k, v]) => [k, v as string]); - return entries.length > 0 ? Object.fromEntries(entries) : undefined; +function validateToolUrl(toolName: string, webhookUrl: string): void { + validateOutboundUrl('Tool', toolName, webhookUrl); } -interface McpOAuthTokens { - accessToken: string; - refreshToken: string; - expiresAt?: number; - expires_at?: number; - scope: string; -} - -interface McpOAuthConfig { - serverUrl: string; - authorizationServerUrl: string; - clientId: string; - redirectUri: string; - resourceUrl: string; - issuedAt: number; - isStatic: boolean; +function validateMcpServerUrl(name: string, serverUrl: string): void { + validateOutboundUrl('MCP server', name, serverUrl); } -/** - * Refreshes an expired OAuth token using the refresh_token grant. - * Writes the updated tokens back to the CLI's token store so both - * copilot-unleashed and the CLI stay in sync. - */ -async function refreshOAuthToken( - oauthConfig: McpOAuthConfig, - tokens: McpOAuthTokens, - tokensPath: string, -): Promise { - if (!tokens.refreshToken || !oauthConfig.clientId) return null; - - // Derive token endpoint from authorization server URL - // authorizationServerUrl is e.g. "https://login.microsoftonline.com/organizations/v2.0" - const tokenUrl = oauthConfig.authorizationServerUrl.replace(/\/v2\.0\/?$/, '/oauth2/v2.0/token'); - - try { - validateOutboundUrl('MCP token endpoint', oauthConfig.serverUrl, tokenUrl); - } catch (err) { - console.warn(`[MCP] Token refresh blocked (SSRF check failed) for ${safeServerLabel(oauthConfig.serverUrl)}`); - return null; - } - - try { - const body = new URLSearchParams({ - grant_type: 'refresh_token', - client_id: oauthConfig.clientId, - refresh_token: tokens.refreshToken, - scope: tokens.scope, - }); - - const response = await fetch(tokenUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: body.toString(), - redirect: 'manual', - }); - - if (!response.ok) { - console.warn(`[MCP] Token refresh failed (${response.status}) for ${safeServerLabel(oauthConfig.serverUrl)}`); - return null; - } - - const data = await response.json() as { - access_token: string; - refresh_token?: string; - expires_in: number; - scope?: string; +function buildUserMcpServers(servers?: McpServerInput[]): Record { + if (!servers || servers.length === 0) return {}; + const result: Record = {}; + for (const server of servers) { + validateMcpServerUrl(server.name, server.url); + result[server.name] = { + type: server.type, + url: server.url, + headers: server.headers, + tools: server.tools.length > 0 ? server.tools : ['*'], + ...(server.timeout && server.timeout > 0 ? { timeout: server.timeout } : {}), }; - - const refreshed: McpOAuthTokens = { - accessToken: data.access_token, - refreshToken: data.refresh_token || tokens.refreshToken, - expiresAt: Math.floor(Date.now() / 1000) + data.expires_in, - scope: data.scope || tokens.scope, - }; - - // Write back atomically so CLI stays in sync - const tmpPath = tokensPath + '.tmp.' + randomUUID(); - await writeFile(tmpPath, JSON.stringify(refreshed), { encoding: 'utf8', mode: 0o600 }); - await rename(tmpPath, tokensPath); - - console.log(`[MCP] Refreshed OAuth token for ${safeServerLabel(oauthConfig.serverUrl)}`); - return refreshed.accessToken; - } catch (err) { - console.warn(`[MCP] Token refresh error:`, err instanceof Error ? err.message : err); - return null; - } -} - -/** - * Reads M365 OAuth tokens managed by the Copilot CLI. - * The CLI stores tokens in ~/.copilot/mcp-oauth-config/{SHA256(serverUrl)}.tokens.json - * after the user authenticates via `copilot /mcp` → re-auth flow. - * If the token is expired but a refresh token exists, attempts to refresh automatically. - */ -async function loadCliOAuthToken( - serverUrl: string, - configDir: string, -): Promise<{ token?: string; expired?: boolean }> { - const hash = createHash('sha256').update(serverUrl).digest('hex'); - const tokensPath = join(configDir, 'mcp-oauth-config', `${hash}.tokens.json`); - const configPath = join(configDir, 'mcp-oauth-config', `${hash}.json`); - - try { - const raw = await readFile(tokensPath, 'utf8'); - const tokens: McpOAuthTokens = JSON.parse(raw); - - if (!tokens.accessToken) { - return {}; - } - - const nowSec = Math.floor(Date.now() / 1000); - const effectiveExpiresAt = tokens.expiresAt ?? tokens.expires_at; - // Refresh 2 minutes before expiry to avoid edge-case failures - if (effectiveExpiresAt && effectiveExpiresAt <= nowSec + 120) { - // Attempt refresh - try { - const configRaw = await readFile(configPath, 'utf8'); - const oauthConfig: McpOAuthConfig = JSON.parse(configRaw); - const refreshed = await refreshOAuthToken(oauthConfig, tokens, tokensPath); - if (refreshed) { - return { token: refreshed }; - } - } catch { - // Config file missing or unreadable - can't refresh - } - return { expired: true }; - } - - return { token: tokens.accessToken }; - } catch { - return {}; - } -} - -/** - * For HTTP MCP servers that require OAuth (no static Authorization header), - * attempt to inject a bearer token from the Copilot CLI's token store. - */ -async function injectOAuthHeaders( - servers: Record, - configDir: string, -): Promise { - const warnings: string[] = []; - - for (const [name, server] of Object.entries(servers)) { - if (server.type !== 'http' && server.type !== 'sse') continue; - - const httpServer = server as { url: string; headers?: Record }; - // Skip servers that already have an Authorization header - if (httpServer.headers?.Authorization || httpServer.headers?.authorization) continue; - - const result = await loadCliOAuthToken(httpServer.url, configDir); - - if (result.expired) { - warnings.push( - `[MCP] OAuth token for "${name}" has expired and refresh failed. Initial CLI auth via \`copilot /mcp\` may be needed.`, - ); - continue; - } - - if (result.token) { - httpServer.headers = { ...httpServer.headers, Authorization: `Bearer ${result.token}` }; - console.log(`[MCP] Injected OAuth token for "${name}" from CLI token store`); - } } - - return warnings; + return result; } -async function loadConfiguredMcpServers(configDir?: string): Promise> { - const resolvedConfigDir = configDir || config.copilotConfigDir || join(homedir(), '.copilot'); - const configPath = join(resolvedConfigDir, 'mcp-config.json'); - - try { - const raw = await readFile(configPath, 'utf8'); - const parsed = JSON.parse(raw) as { mcpServers?: Record>; servers?: Record> }; - const configured = parsed.mcpServers || parsed.servers || {}; - const result: Record = {}; - - for (const [name, server] of Object.entries(configured)) { - const type = typeof server.type === 'string' ? server.type : undefined; - - if ((type === 'http' || type === 'sse') && typeof server.url === 'string') { - validateMcpServerUrl(name, server.url); - result[name] = { - type, - url: server.url, - ...(normalizeStringRecord(server.headers) ? { headers: normalizeStringRecord(server.headers) } : {}), - tools: Array.isArray(server.tools) && server.tools.length > 0 ? server.tools : ['*'], - ...(typeof server.timeout === 'number' && server.timeout > 0 ? { timeout: server.timeout } : {}), - }; - continue; - } - - if ((type === 'stdio' || type === 'local' || typeof server.command === 'string') && typeof server.command === 'string') { - result[name] = { - type: type === 'local' ? 'local' : 'stdio', - command: server.command, - args: Array.isArray(server.args) ? server.args.filter((arg): arg is string => typeof arg === 'string') : [], - ...(normalizeStringRecord(server.env) ? { env: normalizeStringRecord(server.env) } : {}), - ...(typeof server.cwd === 'string' ? { workingDirectory: server.cwd } : {}), - tools: Array.isArray(server.tools) && server.tools.length > 0 ? server.tools : ['*'], - ...(typeof server.timeout === 'number' && server.timeout > 0 ? { timeout: server.timeout } : {}), - }; - } - } - - return result; - } catch (err: unknown) { - if (err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') { - return {}; - } - console.error('[MCP] Failed to load configured MCP servers:', err instanceof Error ? err.message : err); - return {}; - } -} - -export async function buildSessionMcpServers( - githubToken: string, - configDir?: string, -) : Promise> { - const resolvedConfigDir = configDir || config.copilotConfigDir || join(homedir(), '.copilot'); - const configuredMcpServers = await loadConfiguredMcpServers(configDir); - - // Inject OAuth tokens from the Copilot CLI's token store for HTTP servers - const warnings = await injectOAuthHeaders(configuredMcpServers, resolvedConfigDir); - for (const warning of warnings) { - console.warn(warning); - } - - return { - ...configuredMcpServers, - github: { - type: 'http', - url: 'https://api.githubcopilot.com/mcp/x/all', - headers: { - Authorization: `Bearer ${githubToken}`, +function buildCustomTools(customTools: CustomToolDefinition[]) { + return customTools.map((tool) => { + validateToolUrl(tool.name, tool.webhookUrl); + + return defineTool(tool.name, { + description: tool.description, + parameters: buildZodSchema(tool.parameters), + handler: async (args: unknown) => { + const response = await fetch(tool.webhookUrl, { + method: tool.method, + headers: { 'Content-Type': 'application/json', ...tool.headers }, + body: tool.method === 'POST' ? JSON.stringify(args) : undefined, + signal: AbortSignal.timeout(30_000), + redirect: 'manual', + }); + if (!response.ok) throw new Error(`Tool failed: ${response.status}`); + return await response.json(); }, - tools: ['*'], - }, - }; + }); + }); } -/** - * Explicit re-enables for the SDK's "empty" client mode. - * - * Empty mode disables the CLI's ambient capabilities by default (safe for - * multi-user servers). This app opts back into exactly the features it uses, - * keeping behavior parity with the previous "copilot-cli" mode while every - * capability stays an explicit, auditable choice. - */ -export function buildEmptyModeSessionDefaults(): Partial { - if (config.copilotClientMode !== 'empty') return {}; +export function buildSessionHooks(onHookEvent: HookEventCallback): SessionConfig['hooks'] { return { - // Empty mode requires every session to opt into its tools explicitly. - availableTools: new ToolSet().addBuiltIn('*').addMcp('*').addCustom('*').toArray(), - enableSkills: true, - enableConfigDiscovery: true, - enableHostGitOperations: true, - enableSessionStore: true, - enableOnDemandInstructionDiscovery: true, - // Keep MCP OAuth tokens on disk so the Copilot CLI stays in sync - mcpOAuthTokenStorage: 'persistent', - embeddingCacheStorage: 'persistent', - skipEmbeddingRetrieval: false, - skipCustomInstructions: false, - coauthorEnabled: true, - // Deliberately left at empty-mode defaults (off): enableFileHooks - // (hooks are wired via SDK callbacks), enableSessionTelemetry, - // customAgentsLocalOnly stays true, installedPlugins stays []. - }; -} - -export function buildSessionHooks(onHookEvent: HookEventCallback): SessionConfig['hooks'] { return { onPreToolUse: (input) => { onHookEvent({ type: 'hook_pre_tool', toolName: input.toolName, toolArgs: input.toolArgs }); }, onPostToolUse: (input) => { onHookEvent({ type: 'hook_post_tool', toolName: input.toolName, toolArgs: input.toolArgs }); }, - onPostToolUseFailure: (input) => { - onHookEvent({ - type: 'hook_tool_failure', - toolName: input.toolName, - toolArgs: input.toolArgs, - error: input.error, - }); - }, onUserPromptSubmitted: (input) => { onHookEvent({ type: 'hook_user_prompt', prompt: input.prompt }); }, @@ -474,15 +241,22 @@ export async function createCopilotSession( console.log('[SESSION] Creating session with permissionMode:', options.permissionMode || 'approve_all (default)'); const sessionConfig: SessionConfig = { - ...buildEmptyModeSessionDefaults(), clientName: 'copilot-unleashed', - // Omit model when unset so the SDK picks its own default — hardcoding a - // model breaks when it's no longer in the user's available model list. - ...(options.model ? { model: options.model } : {}), + model: options.model || 'gpt-4.1', streaming: true, onPermissionRequest: permissionHandler, - ...(config.copilotConfigDir && { configDirectory: config.copilotConfigDir }), - mcpServers: await buildSessionMcpServers(githubToken, options.configDir), + ...(config.copilotConfigDir && { configDir: config.copilotConfigDir }), + mcpServers: { + github: { + type: 'http', + url: 'https://api.githubcopilot.com/mcp/x/all', + headers: { + Authorization: `Bearer ${githubToken}`, + }, + tools: ['*'], + }, + ...buildUserMcpServers(options.mcpServers), + }, }; if (options.reasoningEffort) { @@ -497,24 +271,17 @@ export async function createCopilotSession( sessionConfig.availableTools = options.availableTools; } - if (options.systemPromptSections && Object.keys(options.systemPromptSections).length > 0) { - const sections: Partial> = { ...options.systemPromptSections }; - if (options.customInstructions && !sections.custom_instructions) { - sections.custom_instructions = { action: 'append', content: options.customInstructions }; - } - sessionConfig.systemMessage = { mode: 'customize', sections }; - } else if (options.customInstructions) { - sessionConfig.systemMessage = { mode: 'append', content: options.customInstructions }; + if (options.customInstructions) { + sessionConfig.systemMessage = { + mode: 'append', + content: options.customInstructions, + }; } if (options.onUserInputRequest) { sessionConfig.onUserInputRequest = options.onUserInputRequest; } - if (options.onElicitationRequest) { - sessionConfig.onElicitationRequest = options.onElicitationRequest; - } - if (options.infiniteSessions) { sessionConfig.infiniteSessions = { enabled: options.infiniteSessions.enabled, @@ -527,8 +294,12 @@ export async function createCopilotSession( }; } + if (options.customTools && options.customTools.length > 0) { + sessionConfig.tools = buildCustomTools(options.customTools); + } + if (options.configDir) { - sessionConfig.configDirectory = options.configDir; + sessionConfig.configDir = options.configDir; } if (options.skillDirectories && options.skillDirectories.length > 0) { @@ -543,42 +314,10 @@ export async function createCopilotSession( sessionConfig.customAgents = options.customAgents; } - if (options.agent && typeof options.agent === 'string') { - sessionConfig.agent = options.agent; - } - - if (options.onEvent) { - sessionConfig.onEvent = options.onEvent; - } - if (options.onHookEvent) { sessionConfig.hooks = buildSessionHooks(options.onHookEvent); } - if (options.modelCapabilities) { - sessionConfig.modelCapabilities = options.modelCapabilities; - } - - if (options.enableConfigDiscovery != null) { - sessionConfig.enableConfigDiscovery = options.enableConfigDiscovery; - } - - if (options.provider) { - sessionConfig.provider = options.provider; - } - - if (options.remoteSession && options.remoteSession !== 'off') { - sessionConfig.remoteSession = options.remoteSession; - } - - if (options.cloud) { - sessionConfig.cloud = options.cloud; - } - - // The SDK 1.0.0-beta runtime writes session state directly to - // `/session-state/` (set via CopilotClient `baseDirectory`), - // so an explicit per-session FS provider is no longer required. - return client.createSession(sessionConfig); } diff --git a/src/lib/server/customizations/scanner.ts b/src/lib/server/customizations/scanner.ts deleted file mode 100644 index 6329d85..0000000 --- a/src/lib/server/customizations/scanner.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { readdir, readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { homedir } from 'node:os'; - -export interface InstructionFile { - name: string; - source: string; - path: string; - content: string; - applyTo?: string; -} - -export interface AgentFile { - name: string; - source: string; - path: string; - description?: string; - tools?: string[]; -} - -export interface PromptFile { - name: string; - source: string; - path: string; - description: string; - content: string; -} - -export interface McpConfigEntry { - name: string; - source: string; - type: string; - url?: string; - command?: string; -} - -export interface DiscoveredCustomizations { - instructions: InstructionFile[]; - agents: AgentFile[]; - prompts: PromptFile[]; - mcpServers: McpConfigEntry[]; -} - -const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---/; - -function parseFrontmatter(raw: string): Record { - const match = raw.match(FRONTMATTER_RE); - if (!match) return {}; - - const result: Record = {}; - for (const line of match[1].split('\n')) { - const colonIdx = line.indexOf(':'); - if (colonIdx < 1) continue; - const key = line.slice(0, colonIdx).trim(); - const value = line.slice(colonIdx + 1).trim().replace(/^['"]|['"]$/g, ''); - if (key && value) result[key] = value; - } - return result; -} - -function parseToolsList(raw: string): string[] | undefined { - // Handle YAML list formats: [a, b, c] or bare comma-separated - const stripped = raw.replace(/^\[|\]$/g, '').trim(); - if (!stripped) return undefined; - return stripped.split(',').map((t) => t.trim()).filter(Boolean); -} - -async function readFileSafe(path: string): Promise { - try { - return await readFile(path, 'utf-8'); - } catch { - return null; - } -} - -async function readdirSafe(path: string): Promise { - try { - return await readdir(path); - } catch { - return []; - } -} - -let cached: DiscoveredCustomizations | null = null; -let cachedAt = 0; -const CACHE_TTL_MS = 30_000; - -/** Scan filesystem locations for Copilot customization files (instructions + agents + prompts). */ -export async function scanCustomizations( - copilotHome?: string, - cwd?: string, -): Promise { - if (cached && Date.now() - cachedAt < CACHE_TTL_MS) return cached; - - const home = copilotHome ?? join(homedir(), '.copilot'); - const root = cwd ?? homedir(); - const instructions: InstructionFile[] = []; - const agents: AgentFile[] = []; - const prompts: PromptFile[] = []; - - // 1. User-level instructions: copilotHome/copilot-instructions.md - const userInstructionsPath = join(home, 'copilot-instructions.md'); - const userContent = await readFileSafe(userInstructionsPath); - if (userContent !== null) { - const fm = parseFrontmatter(userContent); - instructions.push({ - name: fm.description || 'User Instructions', - source: 'user', - path: userInstructionsPath, - content: userContent, - ...(fm.applyTo && { applyTo: fm.applyTo }), - }); - } - - // 2. Repo-wide instructions: cwd/.github/copilot-instructions.md - const repoInstructionsPath = join(root, '.github', 'copilot-instructions.md'); - const repoContent = await readFileSafe(repoInstructionsPath); - if (repoContent !== null) { - const fm = parseFrontmatter(repoContent); - instructions.push({ - name: fm.description || 'Repository Instructions', - source: 'repo', - path: repoInstructionsPath, - content: repoContent, - ...(fm.applyTo && { applyTo: fm.applyTo }), - }); - } - - // 3. Path-specific instructions: cwd/.github/instructions/*.instructions.md - const instructionsDir = join(root, '.github', 'instructions'); - const instructionFiles = await readdirSafe(instructionsDir); - for (const file of instructionFiles) { - if (!file.endsWith('.instructions.md')) continue; - const filePath = join(instructionsDir, file); - const content = await readFileSafe(filePath); - if (content === null) continue; - - const fm = parseFrontmatter(content); - instructions.push({ - name: fm.description || file.replace('.instructions.md', ''), - source: 'repo', - path: filePath, - content, - ...(fm.applyTo && { applyTo: fm.applyTo }), - }); - } - - // 4. Agent instruction files: AGENTS.md, CLAUDE.md, GEMINI.md - const agentInstructionFiles = ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md'] as const; - for (const fileName of agentInstructionFiles) { - const filePath = join(root, fileName); - const content = await readFileSafe(filePath); - if (content === null) continue; - - instructions.push({ - name: fileName.replace('.md', ''), - source: 'repo', - path: filePath, - content, - }); - } - - // 5. Custom agents: cwd/.github/agents/*.agent.md - const agentsDir = join(root, '.github', 'agents'); - const agentFiles = await readdirSafe(agentsDir); - for (const file of agentFiles) { - if (!file.endsWith('.agent.md')) continue; - const filePath = join(agentsDir, file); - const content = await readFileSafe(filePath); - if (content === null) continue; - - const fm = parseFrontmatter(content); - agents.push({ - name: fm.name || file.replace('.agent.md', ''), - source: 'repo', - path: filePath, - ...(fm.description && { description: fm.description }), - ...(fm.tools && { tools: parseToolsList(fm.tools) }), - }); - } - - // 6. User-level agents: copilotHome/agents/*.agent.md - const userAgentsDir = join(home, 'agents'); - const userAgentFiles = await readdirSafe(userAgentsDir); - for (const file of userAgentFiles) { - if (!file.endsWith('.agent.md')) continue; - const filePath = join(userAgentsDir, file); - const content = await readFileSafe(filePath); - if (content === null) continue; - - const fm = parseFrontmatter(content); - agents.push({ - name: fm.name || file.replace('.agent.md', ''), - source: 'user', - path: filePath, - ...(fm.description && { description: fm.description }), - ...(fm.tools && { tools: parseToolsList(fm.tools) }), - }); - } - - // 7. Repo-level prompts: cwd/.github/prompts/*.prompt.md - const repoPromptsDir = join(root, '.github', 'prompts'); - const repoPromptFiles = await readdirSafe(repoPromptsDir); - for (const file of repoPromptFiles) { - if (!file.endsWith('.prompt.md')) continue; - const filePath = join(repoPromptsDir, file); - const content = await readFileSafe(filePath); - if (content === null) continue; - - const fm = parseFrontmatter(content); - const title = file.replace('.prompt.md', ''); - prompts.push({ - name: title, - source: 'repo', - path: filePath, - description: fm.description || title, - content, - }); - } - - // 8. User-level prompts: copilotHome/prompts/*.prompt.md - const userPromptsDir = join(home, 'prompts'); - const userPromptFiles = await readdirSafe(userPromptsDir); - for (const file of userPromptFiles) { - if (!file.endsWith('.prompt.md')) continue; - const filePath = join(userPromptsDir, file); - const content = await readFileSafe(filePath); - if (content === null) continue; - - const fm = parseFrontmatter(content); - const title = file.replace('.prompt.md', ''); - prompts.push({ - name: title, - source: 'user', - path: filePath, - description: fm.description || title, - content, - }); - } - - // 9. MCP servers from mcp-config.json (user-level and repo-level) - const mcpServers: McpConfigEntry[] = []; - for (const [dir, source] of [[home, 'user'], [join(root, '.github'), 'repo']] as const) { - const mcpConfigPath = join(dir, 'mcp-config.json'); - const mcpRaw = await readFileSafe(mcpConfigPath); - if (mcpRaw) { - try { - const parsed = JSON.parse(mcpRaw); - const servers = parsed.mcpServers || parsed.servers || {}; - for (const [name, cfg] of Object.entries(servers)) { - const c = cfg as Record; - const entry: McpConfigEntry = { - name, - source, - type: (c.type as string) || (c.command ? 'stdio' : 'http'), - }; - if (typeof c.url === 'string') entry.url = c.url; - if (typeof c.command === 'string') entry.command = c.command; - mcpServers.push(entry); - } - } catch { /* malformed JSON */ } - } - } - - cached = { instructions, agents, prompts, mcpServers }; - cachedAt = Date.now(); - console.log(`[scanner] Discovered ${instructions.length} instructions, ${agents.length} agents, ${prompts.length} prompts, ${mcpServers.length} mcp servers (home=${home}, root=${root})`); - return cached; -} diff --git a/src/lib/server/logger.ts b/src/lib/server/logger.ts deleted file mode 100644 index 8113158..0000000 --- a/src/lib/server/logger.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Dev-only debug logger — calls are no-ops in production. - * Avoids noisy `[DEBUG …]` lines flooding container logs. - * - * Reads NODE_ENV directly (not via config) so that importing this module - * during build-time analysis (SvelteKit's postbuild) doesn't trigger - * config.ts's fail-fast on missing SESSION_SECRET. - */ -const isDev = process.env.NODE_ENV !== 'production'; - -export const debug: (...args: unknown[]) => void = - isDev - ? (...args: unknown[]) => console.log(...args) - : () => {}; diff --git a/src/lib/server/node-sqlite.d.ts b/src/lib/server/node-sqlite.d.ts new file mode 100644 index 0000000..9865dba --- /dev/null +++ b/src/lib/server/node-sqlite.d.ts @@ -0,0 +1,19 @@ +declare module 'node:sqlite' { + export class DatabaseSync { + constructor(path: string); + exec(sql: string): void; + prepare(sql: string): StatementSync; + close(): void; + } + + interface StatementSync { + run(...params: unknown[]): RunResult; + get(...params: unknown[]): unknown; + all(...params: unknown[]): unknown[]; + } + + interface RunResult { + changes: number; + lastInsertRowid: number | bigint; + } +} diff --git a/src/lib/server/session-store.ts b/src/lib/server/session-store.ts index 74402f4..2594d87 100644 --- a/src/lib/server/session-store.ts +++ b/src/lib/server/session-store.ts @@ -3,7 +3,6 @@ // and SvelteKit server code (bundled by adapter-node). import type { SessionData } from './auth/guard.js'; -import { debug } from './logger.js'; const GLOBAL_KEY = '__copilotSessionMap'; const COUNTER_KEY = '__copilotSessionCounter'; @@ -21,17 +20,17 @@ export function registerSession(session: SessionData): string { g[COUNTER_KEY] = ((g[COUNTER_KEY] as number) || 0) + 1; const id = String(g[COUNTER_KEY]); getMap().set(id, session); - debug(`[SESSION-STORE] register id=${id} hasToken=${!!session.githubToken} user=${session.githubUser?.login ?? 'none'} mapSize=${getMap().size}`); + console.log(`[SESSION-STORE] register id=${id} hasToken=${!!session.githubToken} user=${session.githubUser?.login ?? 'none'} mapSize=${getMap().size}`); return id; } export function getSessionById(id: string): SessionData | undefined { const session = getMap().get(id); - debug(`[SESSION-STORE] get id=${id} found=${!!session} hasToken=${!!session?.githubToken} user=${session?.githubUser?.login ?? 'none'} mapSize=${getMap().size}`); + console.log(`[SESSION-STORE] get id=${id} found=${!!session} hasToken=${!!session?.githubToken} user=${session?.githubUser?.login ?? 'none'} mapSize=${getMap().size}`); return session; } export function deleteSessionById(id: string): void { - debug(`[SESSION-STORE] delete id=${id} mapSize=${getMap().size - 1}`); + console.log(`[SESSION-STORE] delete id=${id} mapSize=${getMap().size - 1}`); getMap().delete(id); } diff --git a/src/lib/server/session-watcher.test.ts b/src/lib/server/session-watcher.test.ts index db54b6f..ec0afa3 100644 --- a/src/lib/server/session-watcher.test.ts +++ b/src/lib/server/session-watcher.test.ts @@ -56,7 +56,6 @@ describe('session-watcher', () => { await sleep(200); mod.stopSessionWatcher(); - changed.mockClear(); await writeFile(join(tempDir, 'after-stop.json'), '{}'); await sleep(300); diff --git a/src/lib/server/session-watcher.ts b/src/lib/server/session-watcher.ts index 87b4a1e..39f1afa 100644 --- a/src/lib/server/session-watcher.ts +++ b/src/lib/server/session-watcher.ts @@ -3,7 +3,6 @@ import { stat } from 'node:fs/promises'; let watcher: FSWatcher | null = null; let debounceTimer: NodeJS.Timeout | null = null; -let watcherGeneration = 0; const DEBOUNCE_MS = 100; @@ -12,13 +11,9 @@ export function startSessionWatcher( onChanged: () => void ): void { stopSessionWatcher(); - const generation = ++watcherGeneration; stat(sessionStatePath) .then((info) => { - if (generation !== watcherGeneration) { - return; - } if (!info.isDirectory()) { console.warn( `[SESSION-WATCHER] path is not a directory: ${sessionStatePath}` @@ -28,14 +23,8 @@ export function startSessionWatcher( try { watcher = watch(sessionStatePath, { recursive: true }, () => { - if (generation !== watcherGeneration) { - return; - } if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { - if (generation !== watcherGeneration) { - return; - } debounceTimer = null; onChanged(); }, DEBOUNCE_MS); @@ -62,7 +51,6 @@ export function startSessionWatcher( } export function stopSessionWatcher(): void { - watcherGeneration += 1; if (debounceTimer) { clearTimeout(debounceTimer); debounceTimer = null; diff --git a/src/lib/server/settings-store.test.ts b/src/lib/server/settings-store.test.ts index 9ea215c..c817851 100644 --- a/src/lib/server/settings-store.test.ts +++ b/src/lib/server/settings-store.test.ts @@ -75,16 +75,20 @@ interface PersistedSettings { model: string; mode: string; reasoningEffort: string; - additionalInstructions: string; + customInstructions: string; excludedTools: string[]; + customTools: unknown[]; + mcpServers?: unknown[]; } const sampleSettings: PersistedSettings = { model: 'gpt-4.1', mode: 'interactive', reasoningEffort: 'medium', - additionalInstructions: 'Be helpful.', + customInstructions: 'Be helpful.', excludedTools: ['bash'], + customTools: [{ name: 'lint' }], + mcpServers: [{ name: 'github' }], }; beforeEach(() => { @@ -156,7 +160,7 @@ describe('saveUserSettings', () => { it('rejects oversized settings payloads before touching the filesystem', async () => { const oversized: PersistedSettings = { ...sampleSettings, - additionalInstructions: 'x'.repeat(60_000), + customInstructions: 'x'.repeat(60_000), }; await expect(saveUserSettings('user', oversized)).rejects.toThrow('Settings data exceeds maximum size'); diff --git a/src/lib/server/settings-store.ts b/src/lib/server/settings-store.ts index 7d05d41..9144c5b 100644 --- a/src/lib/server/settings-store.ts +++ b/src/lib/server/settings-store.ts @@ -7,8 +7,10 @@ interface PersistedSettings { model: string; mode: string; reasoningEffort: string; - additionalInstructions: string; + customInstructions: string; excludedTools: string[]; + customTools: unknown[]; + mcpServers?: unknown[]; } const MAX_FILE_SIZE = 50 * 1024; // 50KB per user diff --git a/src/lib/server/skills/scanner.ts b/src/lib/server/skills/scanner.ts index 2a6b257..3ab25d6 100644 --- a/src/lib/server/skills/scanner.ts +++ b/src/lib/server/skills/scanner.ts @@ -32,7 +32,7 @@ let cachedSkills: SkillDefinition[] | null = null; export async function scanSkills(skillsRoot?: string): Promise { if (cachedSkills) return cachedSkills; - const root = skillsRoot ?? resolve(process.cwd(), '.github', 'skills'); + const root = skillsRoot ?? resolve(process.cwd(), 'skills'); const skills: SkillDefinition[] = []; let entries: string[]; diff --git a/src/lib/server/ws/attachment-validation.test.ts b/src/lib/server/ws/attachment-validation.test.ts index b42fe31..b3fc285 100644 --- a/src/lib/server/ws/attachment-validation.test.ts +++ b/src/lib/server/ws/attachment-validation.test.ts @@ -43,7 +43,7 @@ describe('isValidAttachmentPath', () => { it('rejects paths that match the prefix as a substring but are not inside it', () => { // e.g. /tmp/copilot-uploads-evil/file.txt should not match /tmp/copilot-uploads/ - expect(isValidAttachmentPath(join(UPLOAD_PREFIX + '-evil', 'file.txt'))).toBe(false); + expect(isValidAttachmentPath(UPLOAD_PREFIX + '-evil/file.txt')).toBe(false); }); }); diff --git a/src/lib/server/ws/attachments.ts b/src/lib/server/ws/attachments.ts index 951a8b6..919a867 100644 --- a/src/lib/server/ws/attachments.ts +++ b/src/lib/server/ws/attachments.ts @@ -1,4 +1,4 @@ -import { resolve, sep } from 'node:path'; +import { resolve } from 'node:path'; import { logSecurity } from '../security-log.js'; import { UPLOAD_DIR_PREFIX } from './constants.js'; @@ -6,13 +6,12 @@ import { UPLOAD_DIR_PREFIX } from './constants.js'; export type SdkAttachment = | { type: 'file'; path: string; displayName?: string } | { type: 'directory'; path: string; displayName?: string } - | { type: 'selection'; filePath: string; displayName: string; selection?: { start: { line: number; character: number }; end: { line: number; character: number } }; text?: string } - | { type: 'blob'; data: string; mimeType: string }; + | { type: 'selection'; filePath: string; displayName: string; selection?: { start: { line: number; character: number }; end: { line: number; character: number } }; text?: string }; /** Validate that an attachment path is an absolute path inside the upload directory (prevents arbitrary file reads). */ export function isValidAttachmentPath(filePath: string): boolean { const resolved = resolve(filePath); - return resolved.startsWith(UPLOAD_DIR_PREFIX + sep); + return resolved.startsWith(UPLOAD_DIR_PREFIX + '/'); } /** Map client-sent attachments to the SDK format, validating paths and filtering invalid entries. */ @@ -39,22 +38,6 @@ export function mapAttachmentsToSdk(raw: unknown): SdkAttachment[] | undefined { } if (typeof a.text === 'string') entry.text = a.text; mapped.push(entry); - } else if (attachType === 'blob') { - const data = a.data as string | undefined; - const mimeType = a.mimeType as string | undefined; - if (typeof data !== 'string' || typeof mimeType !== 'string') continue; - // Enforce 10MB base64 limit (~7.5MB decoded) - const MAX_BLOB_SIZE = 10 * 1024 * 1024; - if (data.length > MAX_BLOB_SIZE) { - logSecurity('warn', 'BLOB_SIZE_EXCEEDED', { size: data.length, limit: MAX_BLOB_SIZE }); - continue; - } - // Validate mimeType format - if (!/^[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*\/[a-zA-Z0-9][a-zA-Z0-9!#$&\-^_.+]*$/.test(mimeType)) { - logSecurity('warn', 'BLOB_INVALID_MIMETYPE', { mimeType }); - continue; - } - mapped.push({ type: 'blob', data, mimeType }); } else if (attachType === 'file' || attachType === 'directory') { const path = a.path as string | undefined; const name = (a.displayName ?? a.name) as string | undefined; diff --git a/src/lib/server/ws/constants.ts b/src/lib/server/ws/constants.ts index 50db95c..177c0bd 100644 --- a/src/lib/server/ws/constants.ts +++ b/src/lib/server/ws/constants.ts @@ -4,21 +4,13 @@ import { tmpdir } from 'node:os'; export const MAX_MESSAGE_LENGTH = 10_000; export const VALID_MESSAGE_TYPES = new Set([ - 'new_session', 'new_cloud_session', 'message', 'list_models', 'set_mode', + 'new_session', 'message', 'list_models', 'set_mode', 'abort', 'set_model', 'set_reasoning', 'user_input_response', - 'permission_response', 'elicitation_response', 'ping', + 'permission_response', 'ping', 'list_tools', 'list_agents', 'select_agent', 'deselect_agent', 'get_quota', 'compact', 'list_sessions', 'resume_session', 'delete_session', 'get_session_detail', 'get_plan', 'update_plan', 'delete_plan', 'start_fleet', - 'list_skills_rpc', 'toggle_skill_rpc', 'reload_skills', - 'list_mcp_rpc', 'toggle_mcp_rpc', - 'list_instructions', 'list_prompts', 'use_prompt', - 'list_extensions', 'toggle_extension', 'reload_extensions', - 'shell_exec', 'shell_kill', - 'workspace_list_files', 'workspace_read_file', 'workspace_create_file', 'clear_chat', - 'get_session_history', 'session_log', - 'remote_toggle', ]); export const VALID_MODES = new Set(['interactive', 'plan', 'autopilot']); @@ -32,6 +24,6 @@ export const HEARTBEAT_INTERVAL = 30_000; export const MAX_MISSED_PINGS = 3; export const UPLOAD_DIR_PREFIX = join(tmpdir(), 'copilot-uploads'); -export const RATE_LIMITED_TYPES = new Set(['message', 'new_session', 'new_cloud_session', 'resume_session', 'compact', 'start_fleet']); +export const RATE_LIMITED_TYPES = new Set(['message', 'new_session', 'resume_session', 'compact', 'start_fleet']); export const WS_RATE_LIMIT_MAX = 30; export const WS_RATE_LIMIT_WINDOW_MS = 60_000; diff --git a/src/lib/server/ws/handler.ts b/src/lib/server/ws/handler.ts index 06530b8..ad3bfd9 100644 --- a/src/lib/server/ws/handler.ts +++ b/src/lib/server/ws/handler.ts @@ -14,7 +14,6 @@ import { import { VALID_MESSAGE_TYPES, HEARTBEAT_INTERVAL, MAX_MISSED_PINGS, RATE_LIMITED_TYPES, WS_RATE_LIMIT_MAX, WS_RATE_LIMIT_WINDOW_MS } from './constants.js'; import { messageHandlers } from './message-handlers/index.js'; import { chatStateStore } from '../chat-state-singleton.js'; -import { debug } from '../logger.js'; import type { SessionMiddleware, MessageContext } from './types.js'; export { cleanupAllSessions, cleanupUserSessions } from './session-pool.js'; @@ -41,7 +40,7 @@ export function setupWebSocket( wss.on('close', () => clearInterval(heartbeat)); wss.on('connection', async (ws: WebSocket, req: IncomingMessage) => { - console.log('[WS-SERVER] New connection from', req.socket.remoteAddress); + console.log('[WS-SERVER] New connection attempt from', req.socket.remoteAddress); (ws as any).missedPings = 0; ws.on('pong', () => { (ws as any).missedPings = 0; }); @@ -62,7 +61,7 @@ export function setupWebSocket( }); const session = (req as any).session; - debug('[WS-SERVER] Session extracted:', !!session, 'token:', !!session?.githubToken, 'user:', session?.githubUser?.login); + console.log('[WS-SERVER] Session extracted:', !!session, 'token:', !!session?.githubToken, 'user:', session?.githubUser?.login); // Restore auth from encrypted cookie when session file is missing (e.g. after EmptyDir wipe) if (session && !session.githubToken) { @@ -74,13 +73,13 @@ export function setupWebSocket( session.githubUser = data.githubUser; session.githubAuthTime = data.githubAuthTime; session.save(() => {}); - debug(`[WS-SERVER] Restored auth from cookie for user=${data.githubUser.login}`); + console.log(`[WS-SERVER] Restored auth from cookie for user=${data.githubUser.login}`); } } } const auth = checkAuth(session); - debug('[WS-SERVER] Auth check:', auth.authenticated, auth.error || 'ok'); + console.log('[WS-SERVER] Auth check result:', auth.authenticated, auth.error || 'ok'); if (!auth.authenticated) { logSecurity('warn', 'ws_unauthorized', { ip: req.socket.remoteAddress, @@ -111,11 +110,11 @@ export function setupWebSocket( const tabId = isValidTabId(rawTabId) ? rawTabId : 'default'; const lastSeq = parseInt(reqUrl.searchParams.get('lastSeq') || '-1', 10); const poolKey = `${userLogin}:${tabId}`; - console.log('[WS-SERVER] Authenticated:', userLogin, 'tab:', tabId); + console.log('[WS-SERVER] Authenticated user:', userLogin, 'tab:', tabId, 'lastSeq:', lastSeq, 'checking pool...'); let entry = sessionPool.get(poolKey); if (entry) { - debug('[WS-SERVER] Existing pool entry for', poolKey); + console.log('[WS-SERVER] Existing pool entry found for', poolKey); // Reattach to existing pool entry if (entry.ws && entry.ws !== ws && entry.ws.readyState === WebSocket.OPEN) { entry.ws.close(4002, 'Replaced by new connection'); @@ -137,7 +136,7 @@ export function setupWebSocket( } } - debug('[WS-SERVER] Sending session_reconnected to', poolKey, 'hasSession:', !!entry.session); + console.log('[WS-SERVER] Sending session_reconnected to', poolKey, 'hasSession:', !!entry.session); poolSend(entry, { type: 'session_reconnected', user: userLogin, @@ -171,10 +170,10 @@ export function setupWebSocket( } else { // Create new pool entry — enforce per-user session cap if (countUserSessions(userLogin) >= config.maxSessionsPerUser) { - debug('[WS-SERVER] Session cap reached for', userLogin, '— evicting oldest'); + console.log('[WS-SERVER] Session cap reached for', userLogin, '— evicting oldest'); await evictOldestUserSession(userLogin); } - debug('[WS-SERVER] New pool entry for', poolKey); + console.log('[WS-SERVER] Creating new pool entry for', poolKey); const client = createCopilotClient(githubToken, config.copilotConfigDir); entry = createPoolEntry(client, ws); sessionPool.set(poolKey, entry); @@ -190,7 +189,7 @@ export function setupWebSocket( } const hasPersistedState = !!(persistedState && persistedState.messages.length > 0); - debug('[WS-SERVER] Sending connected to', poolKey, 'persisted:', hasPersistedState); + console.log('[WS-SERVER] Sending connected to', poolKey, 'persisted:', hasPersistedState); poolSend(entry, { type: 'connected', user: userLogin, @@ -214,7 +213,7 @@ export function setupWebSocket( const connectionEntry = entry; ws.on('close', (code: number, reason: Buffer) => { - console.log('[WS-SERVER] Disconnected:', poolKey, 'code:', code); + console.log('[WS-SERVER] Client disconnected:', poolKey, 'code:', code, 'reason:', reason?.toString()); if (connectionEntry.ws === ws) { connectionEntry.ws = null; connectionEntry.ttlTimer = setTimeout(async () => { @@ -232,7 +231,7 @@ export function setupWebSocket( ws.on('message', async (raw) => { try { const msg = JSON.parse(raw.toString()); - debug('[WS-SERVER] Message from', userLogin, ':', msg.type); + console.log('[WS-SERVER] Message from', userLogin, ':', msg.type); if (!msg.type || !VALID_MESSAGE_TYPES.has(msg.type)) { poolSend(connectionEntry, { type: 'error', message: 'Unknown message type' }); diff --git a/src/lib/server/ws/mcp-servers.ts b/src/lib/server/ws/mcp-servers.ts new file mode 100644 index 0000000..11a0c64 --- /dev/null +++ b/src/lib/server/ws/mcp-servers.ts @@ -0,0 +1,32 @@ +/** Parse and validate MCP server entries from a WebSocket message, filtering out disabled servers. */ +export function parseMcpServers(raw: unknown): Array<{ name: string; url: string; type: 'http' | 'sse'; headers: Record; tools: string[]; timeout?: number }> | undefined { + if (!Array.isArray(raw)) return undefined; + const servers = raw + .filter((s: unknown) => { + if (!s || typeof s !== 'object') return false; + const obj = s as Record; + if (obj.enabled === false) return false; + return ( + typeof obj.name === 'string' && + typeof obj.url === 'string' && + (obj.type === 'http' || obj.type === 'sse') && + typeof obj.headers === 'object' && obj.headers !== null && + Array.isArray(obj.tools) + ); + }) + .slice(0, 10) + .map((s: unknown) => { + const obj = s as Record; + return { + name: obj.name as string, + url: obj.url as string, + type: obj.type as 'http' | 'sse', + headers: obj.headers as Record, + tools: (obj.tools as unknown[]).filter((t): t is string => typeof t === 'string'), + ...(typeof obj.timeout === 'number' && obj.timeout > 0 && obj.timeout <= 300000 + ? { timeout: Math.round(obj.timeout) } + : {}), + }; + }); + return servers.length > 0 ? servers : undefined; +} diff --git a/src/lib/server/ws/message-handlers/cloud-session.test.ts b/src/lib/server/ws/message-handlers/cloud-session.test.ts deleted file mode 100644 index 0bb9872..0000000 --- a/src/lib/server/ws/message-handlers/cloud-session.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const { configMock, poolSendMock, createCopilotSessionMock, chatStateDeleteMock, chatStateSaveMock } = vi.hoisted(() => ({ - configMock: { enableRemoteSessions: true, copilotConfigDir: '/copilot-config' }, - poolSendMock: vi.fn(), - createCopilotSessionMock: vi.fn(), - chatStateDeleteMock: vi.fn(), - chatStateSaveMock: vi.fn(async (..._args: unknown[]) => {}), -})); - -vi.mock('../../config.js', () => ({ config: configMock })); -vi.mock('../session-pool.js', () => ({ poolSend: (...args: unknown[]) => poolSendMock(...args) })); -vi.mock('../../copilot/session.js', () => ({ - createCopilotSession: (...args: unknown[]) => createCopilotSessionMock(...args), -})); -vi.mock('../../chat-state-singleton.js', () => ({ - chatStateStore: { - delete: (...args: unknown[]) => chatStateDeleteMock(...args), - save: (...args: unknown[]) => chatStateSaveMock(...args), - }, -})); -vi.mock('../session-events.js', () => ({ - wireSessionEvents: vi.fn(), - createCatchAllHandler: vi.fn(() => vi.fn()), - HANDLED_EVENT_TYPES: new Set(), -})); -vi.mock('../permissions.js', () => ({ - makeUserInputHandler: vi.fn(() => vi.fn()), - makePermissionHandler: vi.fn(() => vi.fn()), - makeElicitationHandler: vi.fn(() => vi.fn()), -})); -vi.mock('../../skills/scanner.js', () => ({ - getSkillDirectories: vi.fn(async () => []), -})); - -import { handleNewCloudSession } from './cloud-session.js'; -import type { MessageContext } from '../types.js'; - -function makeContext(): MessageContext { - return { - connectionEntry: { - client: {}, - session: null, - userInputResolve: null, - permissionResolves: new Map(), - pendingUserInputPrompt: null, - pendingPermissionPrompts: new Map(), - sdkSessionId: null, - model: null, - mode: 'interactive', - } as unknown as MessageContext['connectionEntry'], - githubToken: 'gh-token', - userLogin: 'octocat', - poolKey: 'octocat:tab1', - ws: {} as MessageContext['ws'], - }; -} - -function lastPoolMessage(): Record { - return poolSendMock.mock.calls.at(-1)?.[1] as Record; -} - -describe('handleNewCloudSession', () => { - beforeEach(() => { - vi.clearAllMocks(); - configMock.enableRemoteSessions = true; - createCopilotSessionMock.mockResolvedValue({ - sessionId: 'cloud-session-1', - rpc: { mode: { set: vi.fn(async () => {}) } }, - }); - }); - - it('rejects when remote sessions are disabled server-side', async () => { - configMock.enableRemoteSessions = false; - - await handleNewCloudSession({ type: 'new_cloud_session' }, makeContext()); - - expect(createCopilotSessionMock).not.toHaveBeenCalled(); - expect(lastPoolMessage()).toMatchObject({ type: 'error', message: expect.stringContaining('disabled') }); - }); - - it.each([ - [{ owner: '-bad-', name: 'repo' }, 'Invalid repository owner'], - [{ owner: 'octocat', name: 'bad repo!' }, 'Invalid repository name'], - [{ owner: 'octocat', name: 'repo', branch: '-bad' }, 'Invalid branch name'], - [{ owner: 'octocat', name: 'repo', branch: 'main/' }, 'Invalid branch name'], - [{ owner: 'octocat', name: 'repo', branch: 'feature..bad' }, 'Invalid branch name'], - ])('rejects invalid repository input %j', async (repository, expected) => { - await handleNewCloudSession({ type: 'new_cloud_session', repository }, makeContext()); - - expect(createCopilotSessionMock).not.toHaveBeenCalled(); - expect(lastPoolMessage()).toMatchObject({ type: 'error', message: expected }); - }); - - it('rejects non-object repository values', async () => { - await handleNewCloudSession({ type: 'new_cloud_session', repository: 'octocat/repo' }, makeContext()); - - expect(createCopilotSessionMock).not.toHaveBeenCalled(); - expect(lastPoolMessage()).toMatchObject({ type: 'error' }); - }); - - it('creates a cloud session with a validated repository', async () => { - const ctx = makeContext(); - await handleNewCloudSession({ - type: 'new_cloud_session', - model: 'gpt-4.1', - mode: 'interactive', - repository: { owner: 'octocat', name: 'hello-world', branch: 'main' }, - }, ctx); - - expect(chatStateDeleteMock).toHaveBeenCalledWith('octocat', 'tab1'); - expect(createCopilotSessionMock).toHaveBeenCalledTimes(1); - const options = createCopilotSessionMock.mock.calls[0][2] as Record; - expect(options.cloud).toEqual({ repository: { owner: 'octocat', name: 'hello-world', branch: 'main' } }); - - const created = poolSendMock.mock.calls.find((c) => (c[1] as { type: string }).type === 'cloud_session_created')?.[1]; - expect(created).toMatchObject({ - type: 'cloud_session_created', - sessionId: 'cloud-session-1', - repository: { owner: 'octocat', name: 'hello-world', branch: 'main' }, - }); - expect(chatStateSaveMock).toHaveBeenCalled(); - }); - - it('creates a repository-less cloud session when no repository is given', async () => { - await handleNewCloudSession({ type: 'new_cloud_session', model: 'gpt-4.1' }, makeContext()); - - const options = createCopilotSessionMock.mock.calls[0][2] as Record; - expect(options.cloud).toEqual({}); - }); - - it('reports an error when cloud session creation fails', async () => { - createCopilotSessionMock.mockRejectedValue(new Error('no entitlement')); - vi.spyOn(console, 'error').mockImplementation(() => undefined); - - await handleNewCloudSession({ type: 'new_cloud_session' }, makeContext()); - - expect(lastPoolMessage()).toMatchObject({ - type: 'error', - message: expect.stringContaining('no entitlement'), - }); - }); -}); diff --git a/src/lib/server/ws/message-handlers/cloud-session.ts b/src/lib/server/ws/message-handlers/cloud-session.ts deleted file mode 100644 index 6bb9cf3..0000000 --- a/src/lib/server/ws/message-handlers/cloud-session.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { createCopilotSession } from '../../copilot/session.js'; -import { chatStateStore } from '../../chat-state-singleton.js'; -import { config } from '../../config.js'; -import { poolSend } from '../session-pool.js'; -import { VALID_MODES } from '../constants.js'; -import { wireSessionEvents, createCatchAllHandler, HANDLED_EVENT_TYPES } from '../session-events.js'; -import { makeUserInputHandler, makePermissionHandler, makeElicitationHandler } from '../permissions.js'; -import { getSkillDirectories } from '../../skills/scanner.js'; -import type { MessageContext } from '../types.js'; - -// GitHub owner: alphanumeric + hyphens, no leading/trailing hyphen, max 39 chars. -const OWNER_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/; -// GitHub repo name: alphanumeric, hyphen, underscore, dot; max 100 chars. -const REPO_RE = /^[a-zA-Z0-9._-]{1,100}$/; -// Git branch: conservative allowlist (no control chars, spaces, or git-invalid sequences). -const BRANCH_RE = /^(?!.*\.\.)(?!.*\/$)[a-zA-Z0-9](?:[a-zA-Z0-9._\/-]{0,254})$/; - -function rawTabId(ctx: MessageContext): string { - return ctx.poolKey.split(':').slice(1).join(':'); -} - -interface CloudRepositoryInput { - owner: string; - name: string; - branch?: string; -} - -function parseRepository(raw: unknown): CloudRepositoryInput | { error: string } | null { - if (raw == null) return null; - if (typeof raw !== 'object') return { error: 'repository must be an object' }; - - const obj = raw as Record; - const owner = typeof obj.owner === 'string' ? obj.owner.trim() : ''; - const name = typeof obj.name === 'string' ? obj.name.trim() : ''; - const branch = typeof obj.branch === 'string' ? obj.branch.trim() : undefined; - - if (!OWNER_RE.test(owner)) return { error: 'Invalid repository owner' }; - if (!REPO_RE.test(name)) return { error: 'Invalid repository name' }; - if (branch !== undefined && branch !== '' && !BRANCH_RE.test(branch)) { - return { error: 'Invalid branch name' }; - } - - return { owner, name, ...(branch ? { branch } : {}) }; -} - -/** - * Creates a session that runs on GitHub's cloud agent infrastructure - * instead of locally. The session ID is assigned server-side by GitHub. - */ -export async function handleNewCloudSession(msg: any, ctx: MessageContext): Promise { - const { connectionEntry, githubToken } = ctx; - - if (!config.enableRemoteSessions) { - poolSend(connectionEntry, { type: 'error', message: 'Remote sessions are disabled on this server' }); - return; - } - - const repository = parseRepository(msg.repository); - if (repository && 'error' in repository) { - poolSend(connectionEntry, { type: 'error', message: repository.error }); - return; - } - - // Delete old persisted state before creating new session - chatStateStore.delete(ctx.userLogin, rawTabId(ctx)); - - if (connectionEntry.session) { - try { await connectionEntry.session.disconnect(); } catch { /* ignore */ } - connectionEntry.session = null; - } - connectionEntry.userInputResolve = null; - connectionEntry.permissionResolves.clear(); - connectionEntry.pendingUserInputPrompt = null; - connectionEntry.pendingPermissionPrompts.clear(); - - try { - const skillDirectories = await getSkillDirectories(); - const onEvent = createCatchAllHandler(connectionEntry, HANDLED_EVENT_TYPES); - - connectionEntry.session = await createCopilotSession(connectionEntry.client, githubToken, { - model: msg.model, - reasoningEffort: msg.reasoningEffort, - onUserInputRequest: makeUserInputHandler(connectionEntry, ctx.userLogin), - permissionMode: msg.mode === 'autopilot' ? 'approve_all' : 'prompt', - onPermissionRequest: makePermissionHandler(connectionEntry, ctx.userLogin), - onElicitationRequest: makeElicitationHandler(connectionEntry, ctx.userLogin), - configDir: config.copilotConfigDir, - skillDirectories, - onEvent, - cloud: repository ? { repository } : {}, - onHookEvent: (message) => poolSend(connectionEntry, message), - }); - - wireSessionEvents(connectionEntry.session, connectionEntry, connectionEntry.session?.sessionId, ctx.userLogin, rawTabId(ctx)); - - if (msg.mode && VALID_MODES.has(msg.mode)) { - try { - await connectionEntry.session.rpc.mode.set({ mode: msg.mode }); - } catch (modeErr: any) { - console.warn('Initial mode set failed for cloud session:', modeErr.message); - } - } - - const sessionId = connectionEntry.session?.sessionId; - poolSend(connectionEntry, { - type: 'cloud_session_created', - sessionId, - model: msg.model, - ...(repository ? { repository } : {}), - }); - - connectionEntry.sdkSessionId = sessionId ?? null; - connectionEntry.model = msg.model ?? null; - connectionEntry.mode = msg.mode ?? 'interactive'; - - chatStateStore.save(ctx.userLogin, rawTabId(ctx), { - userId: ctx.userLogin, - tabId: rawTabId(ctx), - sdkSessionId: sessionId ?? null, - model: msg.model ?? '', - mode: msg.mode ?? 'interactive', - messages: [], - createdAt: Date.now(), - updatedAt: Date.now(), - }).catch(() => {}); - } catch (err: any) { - console.error('Cloud session creation error:', err.message); - poolSend(connectionEntry, { - type: 'error', - message: `Failed to create cloud session: ${err.message}`, - }); - } -} diff --git a/src/lib/server/ws/message-handlers/extensions.ts b/src/lib/server/ws/message-handlers/extensions.ts deleted file mode 100644 index 8efa230..0000000 --- a/src/lib/server/ws/message-handlers/extensions.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { poolSend } from '../session-pool.js'; -import type { MessageContext } from '../types.js'; - -export async function handleListExtensions(_msg: unknown, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - if (!connectionEntry.session) { - poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); - return; - } - try { - const result = await connectionEntry.session.rpc.extensions.list(); - const extensions = (result?.extensions || []).map((e: any) => ({ - name: e.name, - description: e.description, - enabled: e.enabled, - })); - poolSend(connectionEntry, { type: 'extensions_list', extensions }); - } catch (err: any) { - console.error('List extensions RPC error:', err.message); - poolSend(connectionEntry, { type: 'error', message: `Failed to list extensions: ${err.message}` }); - } -} - -export async function handleToggleExtension(msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - if (!connectionEntry.session) { - poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); - return; - } - const name = typeof msg.name === 'string' ? msg.name.trim() : ''; - if (!name) { - poolSend(connectionEntry, { type: 'error', message: 'Extension name is required' }); - return; - } - if (typeof msg.enabled !== 'boolean') { - poolSend(connectionEntry, { type: 'error', message: 'enabled (boolean) is required' }); - return; - } - try { - if (msg.enabled) { - await connectionEntry.session.rpc.extensions.enable({ name }); - } else { - await connectionEntry.session.rpc.extensions.disable({ name }); - } - console.log(`Extension ${name} ${msg.enabled ? 'enabled' : 'disabled'} for ${ctx.userLogin}`); - poolSend(connectionEntry, { type: 'extension_toggled', name, enabled: msg.enabled }); - } catch (err: any) { - console.error('Toggle extension RPC error:', err.message); - poolSend(connectionEntry, { type: 'error', message: `Failed to toggle extension: ${err.message}` }); - } -} - -export async function handleReloadExtensions(_msg: unknown, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - if (!connectionEntry.session) { - poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); - return; - } - try { - await connectionEntry.session.rpc.extensions.reload(); - console.log(`Extensions reloaded for ${ctx.userLogin}`); - poolSend(connectionEntry, { type: 'extensions_reloaded' }); - } catch (err: any) { - console.error('Reload extensions RPC error:', err.message); - poolSend(connectionEntry, { type: 'error', message: `Failed to reload extensions: ${err.message}` }); - } -} diff --git a/src/lib/server/ws/message-handlers/index.ts b/src/lib/server/ws/message-handlers/index.ts index 5008fe0..43b2836 100644 --- a/src/lib/server/ws/message-handlers/index.ts +++ b/src/lib/server/ws/message-handlers/index.ts @@ -2,25 +2,17 @@ import type { MessageContext } from '../types.js'; import { handleNewSession } from './new-session.js'; import { handleChat } from './chat.js'; import { handleSetMode, handleAbort, handleSetModel, handleSetReasoning } from './mode-model.js'; -import { handleUserInputResponse, handlePermissionResponse, handleElicitationResponse } from './interactive.js'; +import { handleUserInputResponse, handlePermissionResponse } from './interactive.js'; import { handleListTools, handleListAgents, handleSelectAgent, handleDeselectAgent } from './tools-agents.js'; import { handleGetQuota, handleCompact } from './quota-compact.js'; -import { handleListSessions, handleDeleteSession, handleGetSessionDetail, handleListModels, handleGetSessionHistory, handleSessionLog } from './session-management.js'; +import { handleListSessions, handleDeleteSession, handleGetSessionDetail, handleListModels } from './session-management.js'; import { handleResumeSession } from './resume-session.js'; -import { handleNewCloudSession } from './cloud-session.js'; -import { handleRemoteToggle } from './remote.js'; import { handleGetPlan, handleUpdatePlan, handleDeletePlan } from './plans.js'; import { handleStartFleet } from './fleet.js'; -import { handleListSkillsRpc, handleToggleSkillRpc, handleReloadSkills, handleListMcpRpc, handleToggleMcpRpc, handleListInstructions, handleListPrompts, handleUsePrompt } from './rpc-discovery.js'; -import { handleListExtensions, handleToggleExtension, handleReloadExtensions } from './extensions.js'; -import { handleShellExec, handleShellKill } from './shell.js'; -import { handleWorkspaceListFiles, handleWorkspaceReadFile, handleWorkspaceCreateFile } from './workspace.js'; import { chatStateStore } from '../../chat-state-singleton.js'; export const messageHandlers: Record Promise> = { new_session: handleNewSession, - new_cloud_session: handleNewCloudSession, - remote_toggle: handleRemoteToggle, message: handleChat, list_models: handleListModels, set_mode: handleSetMode, @@ -29,7 +21,6 @@ export const messageHandlers: Record set_reasoning: handleSetReasoning, user_input_response: handleUserInputResponse, permission_response: handlePermissionResponse, - elicitation_response: handleElicitationResponse, list_tools: handleListTools, list_agents: handleListAgents, select_agent: handleSelectAgent, @@ -40,28 +31,10 @@ export const messageHandlers: Record resume_session: handleResumeSession, delete_session: handleDeleteSession, get_session_detail: handleGetSessionDetail, - get_session_history: handleGetSessionHistory, - session_log: handleSessionLog, get_plan: handleGetPlan, update_plan: handleUpdatePlan, delete_plan: handleDeletePlan, start_fleet: handleStartFleet, - list_skills_rpc: handleListSkillsRpc, - toggle_skill_rpc: handleToggleSkillRpc, - reload_skills: handleReloadSkills, - list_mcp_rpc: handleListMcpRpc, - toggle_mcp_rpc: handleToggleMcpRpc, - list_instructions: handleListInstructions, - list_prompts: handleListPrompts, - use_prompt: handleUsePrompt, - list_extensions: handleListExtensions, - toggle_extension: handleToggleExtension, - reload_extensions: handleReloadExtensions, - shell_exec: handleShellExec, - shell_kill: handleShellKill, - workspace_list_files: handleWorkspaceListFiles, - workspace_read_file: handleWorkspaceReadFile, - workspace_create_file: handleWorkspaceCreateFile, clear_chat: async (_msg: any, ctx: MessageContext) => { const tabId = ctx.poolKey.split(':').slice(1).join(':'); chatStateStore.delete(ctx.userLogin, tabId); diff --git a/src/lib/server/ws/message-handlers/interactive.ts b/src/lib/server/ws/message-handlers/interactive.ts index 4d57db1..6022245 100644 --- a/src/lib/server/ws/message-handlers/interactive.ts +++ b/src/lib/server/ws/message-handlers/interactive.ts @@ -50,25 +50,3 @@ export async function handlePermissionResponse(msg: any, ctx: MessageContext): P } permResolve(decision.replace('always_', '')); } - -export async function handleElicitationResponse(msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - if (!connectionEntry.elicitationResolve) { - poolSend(connectionEntry, { type: 'error', message: 'No pending elicitation request' }); - return; - } - - const validActions = new Set(['accept', 'decline', 'cancel']); - const action = (typeof msg.action === 'string' && validActions.has(msg.action) - ? msg.action - : 'cancel') as 'accept' | 'decline' | 'cancel'; - - const resolve = connectionEntry.elicitationResolve; - connectionEntry.elicitationResolve = null; - connectionEntry.pendingElicitationPrompt = null; - resolve({ - action, - ...(action === 'accept' && msg.content ? { content: msg.content as Record } : {}), - }); -} diff --git a/src/lib/server/ws/message-handlers/mode-model.ts b/src/lib/server/ws/message-handlers/mode-model.ts index 831d958..bc2eeb3 100644 --- a/src/lib/server/ws/message-handlers/mode-model.ts +++ b/src/lib/server/ws/message-handlers/mode-model.ts @@ -79,10 +79,7 @@ export async function handleSetModel(msg: any, ctx: MessageContext): Promise ({ - configMock: { - enableRemoteSessions: true, - copilotConfigDir: '/copilot-config', - byokEnabled: false, - }, - poolSendMock: vi.fn(), - createCopilotSessionMock: vi.fn(), - chatStateDeleteMock: vi.fn(), - chatStateSaveMock: vi.fn(async (..._args: unknown[]) => {}), -})); - -vi.mock('../../config.js', () => ({ config: configMock })); -vi.mock('../session-pool.js', () => ({ poolSend: (...args: unknown[]) => poolSendMock(...args) })); -vi.mock('../../copilot/session.js', () => ({ - createCopilotSession: (...args: unknown[]) => createCopilotSessionMock(...args), -})); -vi.mock('../../chat-state-singleton.js', () => ({ - chatStateStore: { - delete: (...args: unknown[]) => chatStateDeleteMock(...args), - save: (...args: unknown[]) => chatStateSaveMock(...args), - }, -})); -vi.mock('../session-events.js', () => ({ - wireSessionEvents: vi.fn(), - createCatchAllHandler: vi.fn(() => vi.fn()), - HANDLED_EVENT_TYPES: new Set(), -})); -vi.mock('../permissions.js', () => ({ - makeUserInputHandler: vi.fn(() => vi.fn()), - makePermissionHandler: vi.fn(() => vi.fn()), - makeElicitationHandler: vi.fn(() => vi.fn()), -})); -vi.mock('../../skills/scanner.js', () => ({ - getSkillDirectories: vi.fn(async () => []), -})); -vi.mock('../../byok/provider-store.js', () => ({ - loadProviderConfig: vi.fn(async () => null), -})); - -import { handleNewSession } from './new-session.js'; -import type { MessageContext } from '../types.js'; - -function makeContext(): MessageContext { - return { - connectionEntry: { - client: {}, - session: null, - userInputResolve: null, - permissionResolves: new Map(), - pendingUserInputPrompt: null, - pendingPermissionPrompts: new Map(), - sdkSessionId: null, - model: null, - mode: 'interactive', - } as unknown as MessageContext['connectionEntry'], - githubToken: 'gh-token', - userLogin: 'octocat', - poolKey: 'octocat:tab1', - ws: {} as MessageContext['ws'], - }; -} - -function sentMessages(): Array> { - return poolSendMock.mock.calls.map((c) => c[1] as Record); -} - -const sdkSession = { sessionId: 'sdk-1', rpc: { mode: { set: vi.fn(async () => {}) } } }; - -describe('handleNewSession model fallback', () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(console, 'warn').mockImplementation(() => undefined); - vi.spyOn(console, 'error').mockImplementation(() => undefined); - }); - - it('creates a session with the requested model', async () => { - createCopilotSessionMock.mockResolvedValue(sdkSession); - - await handleNewSession({ type: 'new_session', model: 'claude-sonnet-4-6' }, makeContext()); - - expect(createCopilotSessionMock).toHaveBeenCalledTimes(1); - const options = createCopilotSessionMock.mock.calls[0][2] as Record; - expect(options.model).toBe('claude-sonnet-4-6'); - expect(sentMessages()).toContainEqual( - expect.objectContaining({ type: 'session_created', model: 'claude-sonnet-4-6' }), - ); - }); - - it('omits the model entirely when the client sends none', async () => { - createCopilotSessionMock.mockResolvedValue(sdkSession); - - await handleNewSession({ type: 'new_session' }, makeContext()); - - const options = createCopilotSessionMock.mock.calls[0][2] as Record; - expect(options.model).toBeUndefined(); - }); - - it('retries with the SDK default when the requested model is not available', async () => { - createCopilotSessionMock - .mockRejectedValueOnce(new Error('Request session.create failed with message: Model "gpt-4.1" is not available.')) - .mockResolvedValueOnce(sdkSession); - - await handleNewSession({ type: 'new_session', model: 'gpt-4.1', reasoningEffort: 'high' }, makeContext()); - - expect(createCopilotSessionMock).toHaveBeenCalledTimes(2); - const retryOptions = createCopilotSessionMock.mock.calls[1][2] as Record; - expect(retryOptions.model).toBeUndefined(); - expect(retryOptions.reasoningEffort).toBeUndefined(); - - // Client is informed and session_created carries no stale model - expect(sentMessages()).toContainEqual( - expect.objectContaining({ type: 'info', message: expect.stringContaining('no longer available') }), - ); - expect(sentMessages()).toContainEqual( - expect.objectContaining({ type: 'session_created', sessionId: 'sdk-1', model: undefined }), - ); - }); - - it('still fails for non-model errors without retrying', async () => { - createCopilotSessionMock.mockRejectedValue(new Error('network down')); - - await handleNewSession({ type: 'new_session', model: 'gpt-5' }, makeContext()); - - expect(createCopilotSessionMock).toHaveBeenCalledTimes(1); - expect(sentMessages()).toContainEqual( - expect.objectContaining({ type: 'error', message: expect.stringContaining('network down') }), - ); - }); -}); diff --git a/src/lib/server/ws/message-handlers/new-session.ts b/src/lib/server/ws/message-handlers/new-session.ts index e33ce86..ed78ab5 100644 --- a/src/lib/server/ws/message-handlers/new-session.ts +++ b/src/lib/server/ws/message-handlers/new-session.ts @@ -1,13 +1,12 @@ import { createCopilotSession } from '../../copilot/session.js'; -import type { SystemMessageSection, SectionOverride } from '@github/copilot-sdk'; import { getSkillDirectories } from '../../skills/scanner.js'; import { config } from '../../config.js'; import { poolSend } from '../session-pool.js'; import { VALID_MODES } from '../constants.js'; -import { wireSessionEvents, createCatchAllHandler, HANDLED_EVENT_TYPES } from '../session-events.js'; -import { makeUserInputHandler, makePermissionHandler, makeElicitationHandler } from '../permissions.js'; +import { parseMcpServers } from '../mcp-servers.js'; +import { wireSessionEvents } from '../session-events.js'; +import { makeUserInputHandler, makePermissionHandler } from '../permissions.js'; import { chatStateStore } from '../../chat-state-singleton.js'; -import { loadProviderConfig } from '../../byok/provider-store.js'; import type { MessageContext } from '../types.js'; function rawTabId(ctx: MessageContext): string { @@ -52,12 +51,9 @@ export async function handleNewSession(msg: any, ctx: MessageContext): Promise typeof s === 'string') @@ -85,85 +81,25 @@ export async function handleNewSession(msg: any, ctx: MessageContext): Promise([ - 'identity', 'tone', 'tool_efficiency', 'environment_context', - 'code_change_rules', 'guidelines', 'safety', 'tool_instructions', - 'custom_instructions', 'last_instructions', - ]); - const validActions = new Set(['replace', 'remove', 'append', 'prepend']); - - let systemPromptSections: Partial> | undefined; - if (msg.systemPromptSections && typeof msg.systemPromptSections === 'object') { - systemPromptSections = {}; - for (const [name, override] of Object.entries(msg.systemPromptSections as Record)) { - if (!validSections.has(name)) continue; - const o = override as Record; - if (!o || typeof o !== 'object' || !validActions.has(o.action as string)) continue; - systemPromptSections[name as SystemMessageSection] = { - action: o.action as SectionOverride['action'], - ...(typeof o.content === 'string' ? { content: o.content.slice(0, 5000) } : {}), - }; - } - } - const skillDirectories = await getSkillDirectories(); - const agent = typeof msg.agent === 'string' ? msg.agent.trim() : undefined; - - // Load BYOK provider config if enabled and user has one configured - let provider: Awaited> = null; - if (config.byokEnabled && msg.useByok !== false) { - provider = await loadProviderConfig(ctx.userLogin); - } - - const onEvent = createCatchAllHandler(connectionEntry, HANDLED_EVENT_TYPES); - - const sessionOptions = { + connectionEntry.session = await createCopilotSession(connectionEntry.client, githubToken, { model: msg.model, reasoningEffort: msg.reasoningEffort, customInstructions, excludedTools, + customTools, infiniteSessions, onUserInputRequest: makeUserInputHandler(connectionEntry, ctx.userLogin), permissionMode, onPermissionRequest: makePermissionHandler(connectionEntry, ctx.userLogin), - onElicitationRequest: makeElicitationHandler(connectionEntry, ctx.userLogin), + mcpServers, configDir: config.copilotConfigDir, skillDirectories, disabledSkills, customAgents, - agent, - onEvent, - systemPromptSections, - ...(msg.modelCapabilities ? { modelCapabilities: msg.modelCapabilities } : {}), - ...(msg.enableConfigDiscovery != null ? { enableConfigDiscovery: msg.enableConfigDiscovery } : {}), - ...(provider ? { provider } : {}), - ...(remoteSession ? { remoteSession } : {}), - onHookEvent: (message: Record) => poolSend(connectionEntry, message), - }; - - let resolvedModel: string | undefined = msg.model; - try { - connectionEntry.session = await createCopilotSession(connectionEntry.client, githubToken, sessionOptions); - } catch (createErr: any) { - // Stale/unavailable model (e.g. persisted default no longer offered) — - // retry once letting the SDK pick its own default model. - if (msg.model && /not available/i.test(createErr?.message ?? '')) { - console.warn(`[SESSION] Model "${msg.model}" unavailable — retrying with SDK default`); - resolvedModel = undefined; - connectionEntry.session = await createCopilotSession(connectionEntry.client, githubToken, { - ...sessionOptions, - model: undefined, - reasoningEffort: undefined, - }); - poolSend(connectionEntry, { - type: 'info', - message: `Model "${msg.model}" is no longer available — switched to the default model.`, - }); - } else { - throw createErr; - } - } + onHookEvent: (message) => poolSend(connectionEntry, message), + }); wireSessionEvents(connectionEntry.session, connectionEntry, connectionEntry.session?.sessionId, ctx.userLogin, rawTabId(ctx)); @@ -178,13 +114,13 @@ export async function handleNewSession(msg: any, ctx: MessageContext): Promise ({ - configMock: { enableRemoteSessions: true }, - poolSendMock: vi.fn(), -})); - -vi.mock('../../config.js', () => ({ config: configMock })); -vi.mock('../session-pool.js', () => ({ poolSend: (...args: unknown[]) => poolSendMock(...args) })); -vi.mock('../../logger.js', () => ({ debug: vi.fn() })); - -import { handleRemoteToggle } from './remote.js'; -import type { MessageContext } from '../types.js'; - -function makeContext(session: unknown): MessageContext { - return { - connectionEntry: { session } as unknown as MessageContext['connectionEntry'], - githubToken: 'gh-token', - userLogin: 'octocat', - poolKey: 'octocat:tab1', - ws: {} as MessageContext['ws'], - }; -} - -function sentMessages(): Array> { - return poolSendMock.mock.calls.map((c) => c[1] as Record); -} - -describe('handleRemoteToggle', () => { - beforeEach(() => { - vi.clearAllMocks(); - configMock.enableRemoteSessions = true; - }); - - it('rejects when remote sessions are disabled server-side', async () => { - configMock.enableRemoteSessions = false; - - await handleRemoteToggle({ type: 'remote_toggle', mode: 'on' }, makeContext({})); - - expect(sentMessages()[0]).toMatchObject({ type: 'error', message: expect.stringContaining('disabled') }); - }); - - it('errors when there is no active session', async () => { - await handleRemoteToggle({ type: 'remote_toggle', mode: 'on' }, makeContext(null)); - - expect(sentMessages()[0]).toMatchObject({ type: 'error', message: 'No active session' }); - }); - - it('disables remote and replies remote_toggled enabled:false', async () => { - const disable = vi.fn(async () => {}); - const session = { rpc: { remote: { disable, enable: vi.fn() } } }; - - await handleRemoteToggle({ type: 'remote_toggle', mode: 'off' }, makeContext(session)); - - expect(disable).toHaveBeenCalledTimes(1); - expect(sentMessages()).toEqual([{ type: 'remote_toggled', enabled: false }]); - }); - - it('enables remote and forwards the github.com URL', async () => { - const enable = vi.fn(async () => ({ url: 'https://github.com/copilot/c/abc', remoteSteerable: true })); - const session = { rpc: { remote: { enable, disable: vi.fn() } } }; - - await handleRemoteToggle({ type: 'remote_toggle', mode: 'on' }, makeContext(session)); - - expect(enable).toHaveBeenCalledWith({ mode: 'on' }); - expect(sentMessages()).toEqual([ - { type: 'remote_toggled', enabled: true }, - { type: 'remote_session_url', url: 'https://github.com/copilot/c/abc' }, - ]); - }); - - it('defaults invalid modes to "export"', async () => { - const enable = vi.fn(async () => ({})); - const session = { rpc: { remote: { enable, disable: vi.fn() } } }; - - await handleRemoteToggle({ type: 'remote_toggle', mode: 'bogus' }, makeContext(session)); - - expect(enable).toHaveBeenCalledWith({ mode: 'export' }); - expect(sentMessages()).toEqual([{ type: 'remote_toggled', enabled: true }]); - }); - - it('reports an error when the RPC fails', async () => { - const enable = vi.fn(async () => { throw new Error('not supported'); }); - const session = { rpc: { remote: { enable, disable: vi.fn() } } }; - vi.spyOn(console, 'error').mockImplementation(() => undefined); - - await handleRemoteToggle({ type: 'remote_toggle', mode: 'export' }, makeContext(session)); - - expect(sentMessages()[0]).toMatchObject({ - type: 'error', - message: expect.stringContaining('not supported'), - }); - }); -}); diff --git a/src/lib/server/ws/message-handlers/remote.ts b/src/lib/server/ws/message-handlers/remote.ts deleted file mode 100644 index 3372433..0000000 --- a/src/lib/server/ws/message-handlers/remote.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { config } from '../../config.js'; -import { poolSend } from '../session-pool.js'; -import { debug } from '../../logger.js'; -import type { MessageContext } from '../types.js'; - -const VALID_REMOTE_MODES = new Set(['off', 'export', 'on']); - -/** - * Toggles remote session export/steering on the active session at runtime - * via the SDK's experimental `session.rpc.remote` surface. - * - * msg.mode: "off" disables; "export" publishes events to GitHub; - * "on" enables export + remote steering (github.com / Mobile). - */ -export async function handleRemoteToggle(msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - if (!config.enableRemoteSessions) { - poolSend(connectionEntry, { type: 'error', message: 'Remote sessions are disabled on this server' }); - return; - } - - const session = connectionEntry.session; - if (!session) { - poolSend(connectionEntry, { type: 'error', message: 'No active session' }); - return; - } - - // Fail-safe default for malformed input: export-only (non-steerable). - const mode = typeof msg.mode === 'string' && VALID_REMOTE_MODES.has(msg.mode) ? msg.mode : 'export'; - - try { - if (mode === 'off') { - await session.rpc.remote.disable(); - poolSend(connectionEntry, { type: 'remote_toggled', enabled: false }); - return; - } - - const result = await session.rpc.remote.enable({ mode }); - poolSend(connectionEntry, { type: 'remote_toggled', enabled: true }); - if (result?.url) { - poolSend(connectionEntry, { type: 'remote_session_url', url: result.url }); - } - debug('[REMOTE] Enabled mode:', mode, 'steerable:', result?.remoteSteerable, 'url:', result?.url); - } catch (err: any) { - console.error('[REMOTE] Toggle error:', err.message); - poolSend(connectionEntry, { type: 'error', message: `Failed to toggle remote session: ${err.message}` }); - } -} diff --git a/src/lib/server/ws/message-handlers/resume-session.ts b/src/lib/server/ws/message-handlers/resume-session.ts index 8efb48b..9dbcbaa 100644 --- a/src/lib/server/ws/message-handlers/resume-session.ts +++ b/src/lib/server/ws/message-handlers/resume-session.ts @@ -1,21 +1,15 @@ import { join } from 'node:path'; import { approveAll } from '@github/copilot-sdk'; -import { createCopilotSession, buildSessionHooks, buildSessionMcpServers, buildEmptyModeSessionDefaults } from '../../copilot/session.js'; +import { createCopilotSession, buildSessionHooks } from '../../copilot/session.js'; import { getSessionDetail, buildSessionContext, isValidSessionId } from '../../copilot/session-metadata.js'; -import { loadSessionTurns } from '../../copilot/session-store-db.js'; -import { chatStateStore } from '../../chat-state-singleton.js'; import { config } from '../../config.js'; import { poolSend } from '../session-pool.js'; import { VALID_MODES } from '../constants.js'; -import { wireSessionEvents, createCatchAllHandler, HANDLED_EVENT_TYPES } from '../session-events.js'; -import { debug } from '../../logger.js'; +import { parseMcpServers } from '../mcp-servers.js'; +import { wireSessionEvents } from '../session-events.js'; import { makeUserInputHandler, makePermissionHandler } from '../permissions.js'; import type { MessageContext } from '../types.js'; -function rawTabId(ctx: MessageContext): string { - return ctx.poolKey.split(':').slice(1).join(':'); -} - export async function handleResumeSession(msg: any, ctx: MessageContext): Promise { const { connectionEntry, githubToken } = ctx; @@ -47,31 +41,40 @@ export async function handleResumeSession(msg: any, ctx: MessageContext): Promis // Read filesystem plan for injection into resumed session context const detail = await getSessionDetail(sessionId); - // Build the full MCP config (GitHub server + config-file servers) - const mcpServersConfig = await buildSessionMcpServers(githubToken, resolvedConfigDir); + // Parse MCP servers from the message so resumed sessions retain MCP access + const resumeMcpServers = parseMcpServers(msg.mcpServers); + + // Build the full MCP config (GitHub server + user servers) + const mcpServersConfig: Record = { + github: { + type: 'http', + url: 'https://api.githubcopilot.com/mcp/x/all', + headers: { Authorization: `Bearer ${githubToken}` }, + tools: ['*'], + }, + }; + if (resumeMcpServers) { + for (const s of resumeMcpServers) { + mcpServersConfig[s.name] = { + type: s.type, + url: s.url, + headers: s.headers, + tools: s.tools.length > 0 ? s.tools : ['*'], + }; + } + } let resumed = false; - const onEvent = createCatchAllHandler(connectionEntry, HANDLED_EVENT_TYPES); - // Try native SDK resume first try { connectionEntry.session = await connectionEntry.client.resumeSession(sessionId, { - ...buildEmptyModeSessionDefaults(), onPermissionRequest: (await import('@github/copilot-sdk')).approveAll, streaming: true, - // Re-prompt any permission requests that were pending when the - // session was last suspended instead of dropping them. - continuePendingWork: true, - // Don't emit a visible resume event when silently re-attaching after reconnect - ...(msg.silent === true ? { suppressResumeEvent: true } : {}), onUserInputRequest: makeUserInputHandler(connectionEntry, ctx.userLogin), hooks: buildSessionHooks((message) => poolSend(connectionEntry, message)), - configDirectory: resolvedConfigDir, + configDir: resolvedConfigDir, mcpServers: mcpServersConfig as any, - onEvent, - ...(msg.modelCapabilities ? { modelCapabilities: msg.modelCapabilities } : {}), - ...(msg.enableConfigDiscovery != null ? { enableConfigDiscovery: msg.enableConfigDiscovery } : {}), ...(detail?.plan && { systemMessage: { mode: 'append' as const, @@ -81,12 +84,12 @@ export async function handleResumeSession(msg: any, ctx: MessageContext): Promis }); resumed = true; } catch (resumeErr: any) { - debug(`[RESUME] SDK resumeSession failed for ${sessionId}: ${resumeErr.message}`); + console.log(`[RESUME] SDK resumeSession failed for ${sessionId}: ${resumeErr.message}`); } // Fallback: create a new session with context from the filesystem session if (!resumed) { - debug(`[RESUME] Attempting context-based fallback for ${sessionId}…`); + console.log(`[RESUME] Attempting context-based fallback for ${sessionId}…`); const context = await buildSessionContext(sessionId); if (!context) { throw new Error(`Session not found: ${sessionId}`); @@ -97,20 +100,18 @@ export async function handleResumeSession(msg: any, ctx: MessageContext): Promis onUserInputRequest: makeUserInputHandler(connectionEntry, ctx.userLogin), permissionMode: 'approve_all', configDir: resolvedConfigDir, - onEvent, + mcpServers: resumeMcpServers, onHookEvent: (message) => poolSend(connectionEntry, message), }); - debug(`[RESUME] Fallback session created for ${sessionId} with context injection`); + console.log(`[RESUME] Fallback session created for ${sessionId} with context injection`); } wireSessionEvents(connectionEntry.session, connectionEntry, sessionId, ctx.userLogin, ctx.poolKey.split(':').slice(1).join(':')); // Read and send the restored session's mode to the client - let resumedMode = 'interactive'; try { const modeResult = await connectionEntry.session.rpc.mode.get(); if (modeResult?.mode && VALID_MODES.has(modeResult.mode)) { - resumedMode = modeResult.mode; poolSend(connectionEntry, { type: 'mode_changed', mode: modeResult.mode }); // Restore correct permission handler for resumed mode if (modeResult.mode === 'autopilot') { @@ -127,7 +128,7 @@ export async function handleResumeSession(msg: any, ctx: MessageContext): Promis if (detail?.plan) { try { await connectionEntry.session.rpc.plan.update({ content: detail.plan }); - debug(`[RESUME] Plan restored into SDK for session ${sessionId}`); + console.log(`[RESUME] Plan restored into SDK for session ${sessionId}`); } catch (planErr: any) { console.warn(`[RESUME] Failed to restore plan into SDK: ${planErr.message}`); } @@ -143,37 +144,6 @@ export async function handleResumeSession(msg: any, ctx: MessageContext): Promis // Non-critical: plan panel will stay hidden } - // Load conversation history from session-store.db and send to browser - try { - const turns = loadSessionTurns(sessionId); - if (turns.length > 0) { - debug(`[RESUME] Loaded ${turns.length} messages from session-store.db for ${sessionId}`); - const resolvedModel = msg.model || ''; - poolSend(connectionEntry, { - type: 'cold_resume', - messages: turns, - model: resolvedModel, - mode: resumedMode, - sdkSessionId: sessionId, - }); - - // Persist to chat-state so subsequent reconnects don't hit the DB again - const tabId = rawTabId(ctx); - chatStateStore.save(ctx.userLogin, tabId, { - userId: ctx.userLogin, - tabId, - sdkSessionId: sessionId, - model: resolvedModel, - mode: resumedMode, - messages: turns as unknown as Array>, - createdAt: turns[0]?.timestamp || Date.now(), - updatedAt: turns[turns.length - 1]?.timestamp || Date.now(), - }).catch(() => {}); - } - } catch (histErr: any) { - console.warn(`[RESUME] Failed to load session history: ${histErr.message}`); - } - poolSend(connectionEntry, { type: 'session_resumed', sessionId }); } catch (err: any) { console.error('Resume session error:', err.message); diff --git a/src/lib/server/ws/message-handlers/rpc-discovery.ts b/src/lib/server/ws/message-handlers/rpc-discovery.ts deleted file mode 100644 index ec1acdb..0000000 --- a/src/lib/server/ws/message-handlers/rpc-discovery.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { poolSend } from '../session-pool.js'; -import type { MessageContext } from '../types.js'; -import { scanCustomizations } from '../../customizations/scanner.js'; -import { config } from '../../config.js'; - -export async function handleListSkillsRpc(_msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - if (!connectionEntry.session) { - poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); - return; - } - try { - const result = await connectionEntry.session.rpc.skills.list(); - const skills = (result?.skills || []).map((s: any) => ({ - ...s, - source: s.source === 'personal' ? 'user' : s.source === 'project' ? 'repo' : 'builtin', - })); - poolSend(connectionEntry, { type: 'skills_list', skills }); - } catch (err: any) { - console.error('List skills RPC error:', err.message); - poolSend(connectionEntry, { type: 'error', message: `Failed to list skills: ${err.message}` }); - } -} - -export async function handleToggleSkillRpc(msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - if (!connectionEntry.session) { - poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); - return; - } - const name = typeof msg.name === 'string' ? msg.name.trim() : ''; - if (!name) { - poolSend(connectionEntry, { type: 'error', message: 'Skill name is required' }); - return; - } - if (typeof msg.enabled !== 'boolean') { - poolSend(connectionEntry, { type: 'error', message: 'enabled (boolean) is required' }); - return; - } - try { - if (msg.enabled) { - await connectionEntry.session.rpc.skills.enable({ name }); - } else { - await connectionEntry.session.rpc.skills.disable({ name }); - } - console.log(`Skill ${name} ${msg.enabled ? 'enabled' : 'disabled'} for ${ctx.userLogin}`); - poolSend(connectionEntry, { type: 'skill_toggled', name, enabled: msg.enabled }); - } catch (err: any) { - console.error('Toggle skill RPC error:', err.message); - poolSend(connectionEntry, { type: 'error', message: `Failed to toggle skill: ${err.message}` }); - } -} - -export async function handleReloadSkills(_msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - if (!connectionEntry.session) { - poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); - return; - } - try { - await connectionEntry.session.rpc.skills.reload(); - console.log(`Skills reloaded for ${ctx.userLogin}`); - poolSend(connectionEntry, { type: 'skills_reloaded' }); - } catch (err: any) { - console.error('Reload skills RPC error:', err.message); - poolSend(connectionEntry, { type: 'error', message: `Failed to reload skills: ${err.message}` }); - } -} - -export async function handleListMcpRpc(_msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - const workspaceRoot = connectionEntry.workspaceGitRoot || connectionEntry.workspaceCwd || config.copilotCwd; - - // If session exists, use SDK RPC for live status - if (connectionEntry.session) { - try { - const result = await connectionEntry.session.rpc.mcp.list(); - const servers = (result?.servers || []).map((s: any) => ({ - ...s, - source: s.source === 'user' ? 'user' : s.source === 'workspace' ? 'repo' : 'builtin', - })); - poolSend(connectionEntry, { type: 'mcp_servers_list', servers }); - return; - } catch (err: any) { - console.error('List MCP servers RPC error, falling back to scanner:', err.message); - } - } - - // Fallback: read mcp-config.json from filesystem - try { - const customizations = await scanCustomizations(config.copilotConfigDir, workspaceRoot || undefined); - const servers = customizations.mcpServers.map(s => ({ - name: s.name, - source: s.source, - status: 'not_configured' as const, - type: s.type, - ...(s.url && { url: s.url }), - ...(s.command && { command: s.command }), - })); - poolSend(connectionEntry, { type: 'mcp_servers_list', servers }); - } catch (err: any) { - console.error('List MCP servers scanner error:', err.message); - poolSend(connectionEntry, { type: 'error', message: `Failed to list MCP servers: ${err.message}` }); - } -} - -export async function handleToggleMcpRpc(msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - if (!connectionEntry.session) { - poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); - return; - } - const name = typeof msg.name === 'string' ? msg.name.trim() : ''; - if (!name) { - poolSend(connectionEntry, { type: 'error', message: 'MCP server name is required' }); - return; - } - if (typeof msg.enabled !== 'boolean') { - poolSend(connectionEntry, { type: 'error', message: 'enabled (boolean) is required' }); - return; - } - try { - if (msg.enabled) { - await connectionEntry.session.rpc.mcp.enable({ name }); - } else { - await connectionEntry.session.rpc.mcp.disable({ name }); - } - console.log(`MCP server ${name} ${msg.enabled ? 'enabled' : 'disabled'} for ${ctx.userLogin}`); - poolSend(connectionEntry, { type: 'mcp_server_toggled', name, enabled: msg.enabled }); - } catch (err: any) { - console.error('Toggle MCP server RPC error:', err.message); - poolSend(connectionEntry, { type: 'error', message: `Failed to toggle MCP server: ${err.message}` }); - } -} - -export async function handleListInstructions(_msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - const workspaceRoot = connectionEntry.workspaceGitRoot || connectionEntry.workspaceCwd || config.copilotCwd; - try { - const customizations = await scanCustomizations(config.copilotConfigDir, workspaceRoot || undefined); - const instructions = customizations.instructions.map(({ name, source, path, applyTo }) => ({ - name, - source, - path, - ...(applyTo && { applyTo }), - })); - poolSend(connectionEntry, { type: 'instructions_list', instructions }); - } catch (err: any) { - console.error('List instructions error:', err.message); - poolSend(connectionEntry, { type: 'error', message: `Failed to list instructions: ${err.message}` }); - } -} - -export async function handleListPrompts(_msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - const workspaceRoot = connectionEntry.workspaceGitRoot || connectionEntry.workspaceCwd || config.copilotCwd; - try { - const customizations = await scanCustomizations(config.copilotConfigDir, workspaceRoot || undefined); - const prompts = customizations.prompts.map(({ name, source, path, description, content }) => ({ - name, - source, - path, - description, - content, - })); - poolSend(connectionEntry, { type: 'prompts_list', prompts }); - } catch (err: any) { - console.error('List prompts error:', err.message); - poolSend(connectionEntry, { type: 'error', message: `Failed to list prompts: ${err.message}` }); - } -} - -export async function handleUsePrompt(msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - const workspaceRoot = connectionEntry.workspaceGitRoot || connectionEntry.workspaceCwd || config.copilotCwd; - const name = typeof msg.name === 'string' ? msg.name.trim() : ''; - if (!name) { - poolSend(connectionEntry, { type: 'error', message: 'Prompt name is required' }); - return; - } - try { - const customizations = await scanCustomizations(config.copilotConfigDir, workspaceRoot || undefined); - const prompt = customizations.prompts.find(p => p.name === name); - if (!prompt) { - poolSend(connectionEntry, { type: 'error', message: `Prompt not found: ${name}` }); - return; - } - poolSend(connectionEntry, { type: 'prompt_content', name: prompt.name, content: prompt.content }); - } catch (err: any) { - console.error('Use prompt error:', err.message); - poolSend(connectionEntry, { type: 'error', message: `Failed to get prompt: ${err.message}` }); - } -} diff --git a/src/lib/server/ws/message-handlers/session-management.ts b/src/lib/server/ws/message-handlers/session-management.ts index bbde5a2..62bc4f5 100644 --- a/src/lib/server/ws/message-handlers/session-management.ts +++ b/src/lib/server/ws/message-handlers/session-management.ts @@ -1,17 +1,19 @@ import { getAvailableModels } from '../../copilot/session.js'; import { enrichSessionMetadata, getSessionDetail, listSessionsFromFilesystem, deleteSessionFromFilesystem, isValidSessionId } from '../../copilot/session-metadata.js'; import { poolSend } from '../session-pool.js'; -import { debug } from '../../logger.js'; import type { MessageContext } from '../types.js'; export async function handleListSessions(msg: any, ctx: MessageContext): Promise { const { connectionEntry } = ctx; try { + // start() is idempotent — no-op if already connected + console.log('[DEBUG list_sessions] Starting client…'); await connectionEntry.client.start(); + console.log('[DEBUG list_sessions] client.listSessions()…'); const sessions = await connectionEntry.client.listSessions(); const rawList = Array.isArray(sessions) ? sessions : []; - debug('[LIST_SESSIONS] SDK returned', rawList.length, 'sessions'); + console.log('[DEBUG list_sessions] SDK returned', rawList.length, 'sessions'); // Enrich each session with filesystem metadata in parallel const sdkSessions = await Promise.all( @@ -34,31 +36,30 @@ export async function handleListSessions(msg: any, ctx: MessageContext): Promise // Merge with filesystem sessions the SDK may not know about // (e.g. bundled sessions copied into a fresh container) - debug('[LIST_SESSIONS] Scanning filesystem…'); + console.log('[DEBUG list_sessions] Scanning filesystem…'); const fsSessions = await listSessionsFromFilesystem(); - debug('[LIST_SESSIONS] Filesystem found', fsSessions.length, 'sessions'); + console.log('[DEBUG list_sessions] Filesystem found', fsSessions.length, 'sessions'); const sdkIds = new Set(sdkSessions.map((s) => s.id)); const extraSessions = fsSessions.filter((s) => !sdkIds.has(s.id)).map((s) => ({ ...s, source: 'filesystem' as const })); const allSessions = [...sdkSessions, ...extraSessions]; // Filter out sessions with no meaningful content — these are SDK-internal // empty sessions created at startup or after disconnects that were never used. - // cwd is not a reliable signal: non-Docker runs set a real cwd on phantom sessions. const list = allSessions.filter((s) => - s.title || s.checkpointCount > 0 || s.hasPlan, + s.title || s.checkpointCount > 0 || s.hasPlan || (s.cwd && s.cwd !== '/home/node'), ); - debug('[LIST_SESSIONS] Sending', list.length, 'total (SDK:', sdkSessions.length, '+ FS extra:', extraSessions.length, ', filtered out:', allSessions.length - list.length, ')'); + console.log('[DEBUG list_sessions] Sending', list.length, 'total (SDK:', sdkSessions.length, '+ FS extra:', extraSessions.length, ', filtered out:', allSessions.length - list.length, ')'); poolSend(connectionEntry, { type: 'sessions', sessions: list }); } catch (err: any) { - console.error('[LIST_SESSIONS] SDK error:', err.message); + console.error('[DEBUG list_sessions] SDK error:', err.message); // SDK failed — fall back to filesystem-only listing try { const fsSessions = await listSessionsFromFilesystem(); - debug('[LIST_SESSIONS] Fallback: filesystem found', fsSessions.length, 'sessions'); + console.log('[DEBUG list_sessions] Fallback: filesystem found', fsSessions.length, 'sessions'); poolSend(connectionEntry, { type: 'sessions', sessions: fsSessions.map((s) => ({ ...s, source: 'filesystem' as const })) }); } catch (fsErr: any) { - console.error('[LIST_SESSIONS] Filesystem fallback also failed:', fsErr.message); + console.error('[DEBUG list_sessions] Filesystem fallback also failed:', fsErr.message); poolSend(connectionEntry, { type: 'sessions', sessions: [] }); } } @@ -107,7 +108,9 @@ export async function handleGetSessionDetail(msg: any, ctx: MessageContext): Pro const { connectionEntry } = ctx; const detailId = typeof msg.sessionId === 'string' ? msg.sessionId.trim() : ''; + console.log('[DEBUG get_session_detail] Requested:', JSON.stringify(detailId)); if (!detailId) { + console.log('[DEBUG get_session_detail] Empty ID, sending error'); poolSend(connectionEntry, { type: 'error', message: 'Session ID is required' }); return; } @@ -117,74 +120,21 @@ export async function handleGetSessionDetail(msg: any, ctx: MessageContext): Pro } try { + console.log('[DEBUG get_session_detail] Calling getSessionDetail…'); const detail = await getSessionDetail(detailId); - debug('[SESSION_DETAIL] Result:', detail ? `found (id=${detail.id})` : 'null'); + console.log('[DEBUG get_session_detail] Result:', detail ? `found (id=${detail.id})` : 'null'); if (!detail) { poolSend(connectionEntry, { type: 'error', message: 'Session not found' }); return; } poolSend(connectionEntry, { type: 'session_detail', detail }); + console.log('[DEBUG get_session_detail] Sent session_detail response'); } catch (err: any) { - console.error('[SESSION_DETAIL] Error:', err.message); + console.error('[DEBUG get_session_detail] Error:', err.message, err.stack); poolSend(connectionEntry, { type: 'error', message: `Failed to get session detail: ${err.message}` }); } } -export async function handleGetSessionHistory(msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - const session = connectionEntry.session; - if (!session) { - poolSend(connectionEntry, { type: 'error', message: 'No active session' }); - return; - } - - try { - const events = await session.getEvents(); - const eventList = Array.isArray(events) ? events : []; - debug('[SESSION_HISTORY] Got', eventList.length, 'events'); - - const messages: Array<{ id: string; role: 'user' | 'assistant'; content: string; timestamp: number }> = eventList - .filter((e: any) => e.type === 'assistant.message' || e.type === 'user.message') - .map((e: any, i: number) => ({ - id: `hist-${i}-${Date.now()}`, - role: e.type === 'user.message' ? 'user' as const : 'assistant' as const, - content: e.data?.content ?? '', - timestamp: e.timestamp ? new Date(e.timestamp).getTime() : Date.now(), - })); - - poolSend(connectionEntry, { type: 'session_history', messages }); - } catch (err: any) { - console.error('[SESSION_HISTORY] Error:', err.message); - poolSend(connectionEntry, { type: 'error', message: `Failed to get session history: ${err.message}` }); - } -} - -export async function handleSessionLog(msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - const session = connectionEntry.session; - if (!session) { - poolSend(connectionEntry, { type: 'error', message: 'No active session' }); - return; - } - - const logMessage = typeof msg.message === 'string' ? msg.message.trim() : ''; - if (!logMessage) { - poolSend(connectionEntry, { type: 'error', message: 'Log message is required' }); - return; - } - - try { - const level = msg.level === 'warning' || msg.level === 'error' ? msg.level : 'info'; - await session.log(logMessage, { level }); - debug('[SESSION_LOG] Logged:', level, logMessage.slice(0, 80)); - } catch (err: any) { - console.error('[SESSION_LOG] Error:', err.message); - poolSend(connectionEntry, { type: 'error', message: `Failed to log message: ${err.message}` }); - } -} - export async function handleListModels(msg: any, ctx: MessageContext): Promise { const { connectionEntry } = ctx; diff --git a/src/lib/server/ws/message-handlers/shell.ts b/src/lib/server/ws/message-handlers/shell.ts deleted file mode 100644 index 74798bd..0000000 --- a/src/lib/server/ws/message-handlers/shell.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { poolSend } from '../session-pool.js'; -import type { MessageContext } from '../types.js'; - -export async function handleShellExec(msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - if (!connectionEntry.session) { - poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); - return; - } - - const command = typeof msg.command === 'string' ? msg.command : ''; - if (!command) { - poolSend(connectionEntry, { type: 'error', message: 'command (string) is required' }); - return; - } - - console.log(`[SHELL] User ${ctx.userLogin} executing command (${command.length} chars)`); - - try { - const params: { command: string; cwd?: string; timeout?: number } = { command }; - if (typeof msg.cwd === 'string') params.cwd = msg.cwd; - if (typeof msg.timeout === 'number') params.timeout = msg.timeout; - - const result = await connectionEntry.session.rpc.shell.exec(params); - poolSend(connectionEntry, { - type: 'shell_exec_result', - stdout: result?.stdout ?? '', - stderr: result?.stderr ?? '', - exitCode: result?.exitCode ?? null, - ...(result?.pid != null && { pid: result.pid }), - }); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - console.error('Shell exec RPC error:', message); - poolSend(connectionEntry, { type: 'error', message: `Shell exec failed: ${message}` }); - } -} - -export async function handleShellKill(msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - if (!connectionEntry.session) { - poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); - return; - } - - const pid = typeof msg.pid === 'number' ? msg.pid : NaN; - if (!Number.isFinite(pid)) { - poolSend(connectionEntry, { type: 'error', message: 'pid (number) is required' }); - return; - } - - try { - const result = await connectionEntry.session.rpc.shell.kill({ pid }); - poolSend(connectionEntry, { - type: 'shell_kill_result', - success: result?.success ?? false, - }); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - console.error('Shell kill RPC error:', message); - poolSend(connectionEntry, { type: 'error', message: `Shell kill failed: ${message}` }); - } -} diff --git a/src/lib/server/ws/message-handlers/tools-agents.ts b/src/lib/server/ws/message-handlers/tools-agents.ts index 5c9e065..56e263a 100644 --- a/src/lib/server/ws/message-handlers/tools-agents.ts +++ b/src/lib/server/ws/message-handlers/tools-agents.ts @@ -1,7 +1,5 @@ import { poolSend } from '../session-pool.js'; import type { MessageContext } from '../types.js'; -import { scanCustomizations } from '../../customizations/scanner.js'; -import { config } from '../../config.js'; export async function handleListTools(msg: any, ctx: MessageContext): Promise { const { connectionEntry } = ctx; @@ -18,7 +16,6 @@ export async function handleListTools(msg: any, ctx: MessageContext): Promise { const { connectionEntry } = ctx; - const workspaceRoot = connectionEntry.workspaceGitRoot || connectionEntry.workspaceCwd || config.copilotCwd; if (!connectionEntry.session) { poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); @@ -30,33 +27,7 @@ export async function handleListAgents(msg: any, ctx: MessageContext): Promise ({ name: a.name, source: a.source })); - } catch { /* scanner failure is non-fatal */ } - - const currentName = current?.agent?.name ?? current?.agent ?? null; - const scannedMap = new Map(scannedAgents.map(a => [a.name.toLowerCase(), a.source])); - - const sourcedAgents = (agents?.agents || []).map((a: any) => { - const name = typeof a === 'string' ? a : a.name; - const displayName = typeof a === 'string' ? undefined : a.displayName; - const description = typeof a === 'string' ? undefined : a.description; - const scanSource = scannedMap.get(name.toLowerCase()); - const source = scanSource === 'user' ? 'user' : scanSource === 'repo' ? 'repo' : 'builtin'; - return { - name, - ...(displayName && { displayName }), - ...(description && { description }), - source, - isSelected: name === currentName, - }; - }); - - poolSend(connectionEntry, { type: 'agents', agents: sourcedAgents, current: currentName }); + poolSend(connectionEntry, { type: 'agents', agents: agents?.agents || [], current: current?.agent || null }); } catch (err: any) { console.error('List agents error:', err.message); poolSend(connectionEntry, { type: 'agents', agents: [], current: null }); diff --git a/src/lib/server/ws/message-handlers/workspace.ts b/src/lib/server/ws/message-handlers/workspace.ts deleted file mode 100644 index e5c4fee..0000000 --- a/src/lib/server/ws/message-handlers/workspace.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { poolSend } from '../session-pool.js'; -import type { MessageContext } from '../types.js'; - -export async function handleWorkspaceListFiles(_msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - if (!connectionEntry.session) { - poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); - return; - } - - try { - const result = await connectionEntry.session.rpc.workspace.listFiles(); - poolSend(connectionEntry, { - type: 'workspace_files_list', - files: result?.files ?? [], - }); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - console.error('Workspace listFiles RPC error:', message); - poolSend(connectionEntry, { type: 'error', message: 'Failed to list workspace files' }); - } -} - -export async function handleWorkspaceReadFile(msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - if (!connectionEntry.session) { - poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); - return; - } - - const path = typeof msg.path === 'string' ? msg.path : ''; - if (!path) { - poolSend(connectionEntry, { type: 'error', message: 'path (string) is required' }); - return; - } - - try { - const result = await connectionEntry.session.rpc.workspace.readFile({ path }); - poolSend(connectionEntry, { - type: 'workspace_file_content', - path, - content: result?.content ?? '', - }); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - console.error('Workspace readFile RPC error:', message); - poolSend(connectionEntry, { type: 'error', message: 'Failed to read file' }); - } -} - -export async function handleWorkspaceCreateFile(msg: any, ctx: MessageContext): Promise { - const { connectionEntry } = ctx; - - if (!connectionEntry.session) { - poolSend(connectionEntry, { type: 'error', message: 'No active session. Send new_session first.' }); - return; - } - - const path = typeof msg.path === 'string' ? msg.path : ''; - if (!path) { - poolSend(connectionEntry, { type: 'error', message: 'path (string) is required' }); - return; - } - if (typeof msg.content !== 'string') { - poolSend(connectionEntry, { type: 'error', message: 'content (string) is required' }); - return; - } - - try { - await connectionEntry.session.rpc.workspace.createFile({ path, content: msg.content }); - poolSend(connectionEntry, { - type: 'workspace_file_created', - path, - }); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - console.error('Workspace createFile RPC error:', message); - poolSend(connectionEntry, { type: 'error', message: 'Failed to create file' }); - } -} diff --git a/src/lib/server/ws/parse-mcp-servers.test.ts b/src/lib/server/ws/parse-mcp-servers.test.ts new file mode 100644 index 0000000..9dde7a7 --- /dev/null +++ b/src/lib/server/ws/parse-mcp-servers.test.ts @@ -0,0 +1,136 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { parseMcpServers } from './mcp-servers.js'; + +describe('parseMcpServers', () => { + it('returns undefined for non-array input', () => { + expect(parseMcpServers(undefined)).toBeUndefined(); + expect(parseMcpServers(null)).toBeUndefined(); + expect(parseMcpServers('string')).toBeUndefined(); + expect(parseMcpServers(42)).toBeUndefined(); + }); + + it('returns undefined for an empty array', () => { + expect(parseMcpServers([])).toBeUndefined(); + }); + + it('parses valid HTTP and SSE servers', () => { + const result = parseMcpServers([ + { name: 'api', url: 'https://api.example.com/mcp', type: 'http', headers: { 'X-Key': 'k1' }, tools: ['search'] }, + { name: 'stream', url: 'https://stream.example.com/events', type: 'sse', headers: {}, tools: [] }, + ]); + + expect(result).toEqual([ + { name: 'api', url: 'https://api.example.com/mcp', type: 'http', headers: { 'X-Key': 'k1' }, tools: ['search'] }, + { name: 'stream', url: 'https://stream.example.com/events', type: 'sse', headers: {}, tools: [] }, + ]); + }); + + it('filters out servers with enabled === false', () => { + const result = parseMcpServers([ + { name: 'active', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [], enabled: true }, + { name: 'disabled', url: 'https://b.example.com/mcp', type: 'http', headers: {}, tools: [], enabled: false }, + ]); + + expect(result).toEqual([ + { name: 'active', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [] }, + ]); + }); + + it('returns undefined when all servers are disabled', () => { + const result = parseMcpServers([ + { name: 's1', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [], enabled: false }, + { name: 's2', url: 'https://b.example.com/mcp', type: 'sse', headers: {}, tools: [], enabled: false }, + ]); + + expect(result).toBeUndefined(); + }); + + it('includes servers without an enabled field (defaults to enabled)', () => { + const result = parseMcpServers([ + { name: 'no-flag', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [] }, + ]); + + expect(result).toHaveLength(1); + expect(result![0].name).toBe('no-flag'); + }); + + it('rejects entries with missing or invalid fields', () => { + const result = parseMcpServers([ + null, + { name: 'missing-url', type: 'http', headers: {}, tools: [] }, + { name: 'bad-type', url: 'https://a.example.com', type: 'grpc', headers: {}, tools: [] }, + { name: 'no-headers', url: 'https://a.example.com', type: 'http', tools: [] }, + { name: 'no-tools', url: 'https://a.example.com', type: 'http', headers: {} }, + 'not-an-object', + ]); + + expect(result).toBeUndefined(); + }); + + it('limits to 10 servers', () => { + const servers = Array.from({ length: 15 }, (_, i) => ({ + name: `server-${i}`, + url: `https://s${i}.example.com/mcp`, + type: 'http' as const, + headers: {}, + tools: [], + })); + + const result = parseMcpServers(servers); + expect(result).toHaveLength(10); + expect(result![9].name).toBe('server-9'); + }); + + it('filters non-string values from tools arrays', () => { + const result = parseMcpServers([ + { name: 'mixed-tools', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: ['valid', 42, null, 'also-valid'] }, + ]); + + expect(result![0].tools).toEqual(['valid', 'also-valid']); + }); + + it('includes valid timeout in parsed server', () => { + const result = parseMcpServers([ + { name: 'with-timeout', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [], timeout: 60000 }, + ]); + + expect(result![0].timeout).toBe(60000); + }); + + it('rounds non-integer timeout to nearest integer', () => { + const result = parseMcpServers([ + { name: 's1', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [], timeout: 5000.7 }, + ]); + + expect(result![0].timeout).toBe(5001); + }); + + it('excludes timeout exceeding max (300000ms)', () => { + const result = parseMcpServers([ + { name: 's1', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [], timeout: 500000 }, + ]); + + expect(result![0].timeout).toBeUndefined(); + }); + + it('excludes non-number timeout', () => { + const result = parseMcpServers([ + { name: 's1', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [], timeout: 'fast' }, + ]); + + expect(result![0].timeout).toBeUndefined(); + }); + + it('excludes zero and negative timeout', () => { + const zeroResult = parseMcpServers([ + { name: 's1', url: 'https://a.example.com/mcp', type: 'http', headers: {}, tools: [], timeout: 0 }, + ]); + expect(zeroResult![0].timeout).toBeUndefined(); + + const negativeResult = parseMcpServers([ + { name: 's2', url: 'https://b.example.com/mcp', type: 'http', headers: {}, tools: [], timeout: -1000 }, + ]); + expect(negativeResult![0].timeout).toBeUndefined(); + }); +}); diff --git a/src/lib/server/ws/permissions.ts b/src/lib/server/ws/permissions.ts index cf2bb52..80d34cd 100644 --- a/src/lib/server/ws/permissions.ts +++ b/src/lib/server/ws/permissions.ts @@ -2,7 +2,6 @@ import { WebSocket } from 'ws'; import { poolSend, type PoolEntry } from './session-pool.js'; import { sendPushToUser } from '../push/sender.js'; import { subscriptionStore } from '../push-singleton.js'; -import type { ElicitationResult } from '@github/copilot-sdk'; export function makeUserInputHandler(entry: PoolEntry, userLogin?: string) { return (request: any) => { @@ -30,9 +29,9 @@ export function makeUserInputHandler(entry: PoolEntry, userLogin?: string) { }; } -const PERMISSION_TIMEOUT_MS = 300_000; // 5 minutes +export const PERMISSION_TIMEOUT_MS = 300_000; // 5 minutes -function extractPermissionDisplay(request: any): { +export function extractPermissionDisplay(request: any): { kind: string; toolName: string; toolArgs: Record; @@ -106,41 +105,6 @@ function extractPermissionDisplay(request: any): { } } -export function makeElicitationHandler(entry: PoolEntry, userLogin?: string) { - return (context: any) => { - return new Promise((resolve) => { - // Auto-cancel any pending elicitation to prevent deadlocks - if (entry.elicitationResolve) { - const prev = entry.elicitationResolve; - entry.elicitationResolve = null; - entry.pendingElicitationPrompt = null; - prev({ action: 'cancel' }); - } - entry.elicitationResolve = resolve; - const prompt = { - type: 'elicitation_requested', - elicitationId: context.elicitationId ?? `elic-${Date.now()}`, - message: context.message, - requestedSchema: context.requestedSchema, - mode: context.mode, - elicitationSource: context.elicitationSource, - }; - entry.pendingElicitationPrompt = prompt; - poolSend(entry, prompt); - - // Push notification when browser is closed - if ((!entry.ws || entry.ws.readyState !== WebSocket.OPEN) && userLogin) { - sendPushToUser(userLogin, { - title: 'Copilot needs your input', - body: context.message?.slice(0, 100) || 'A form needs your attention', - url: '/', - tag: 'elicitation-request', - }, subscriptionStore).catch(() => {}); - } - }); - }; -} - export function makePermissionHandler(entry: PoolEntry, userLogin?: string) { return (request: any) => { const { kind, toolName, toolArgs } = extractPermissionDisplay(request); diff --git a/src/lib/server/ws/quota.ts b/src/lib/server/ws/quota.ts index 385cb84..a093d84 100644 --- a/src/lib/server/ws/quota.ts +++ b/src/lib/server/ws/quota.ts @@ -1,6 +1,7 @@ /** Normalize SDK quota snapshots: convert remainingPercentage from 0.0–1.0 to 0–100 and add percentageUsed */ export function normalizeQuotaSnapshots(raw: Record | undefined): Record | undefined { if (!raw) return raw; + console.log('[QUOTA] raw SDK snapshots:', JSON.stringify(raw)); const result: Record = {}; for (const [key, snap] of Object.entries(raw)) { const remaining = snap.remainingPercentage; diff --git a/src/lib/server/ws/session-events.ts b/src/lib/server/ws/session-events.ts index e5169e0..f34acd4 100644 --- a/src/lib/server/ws/session-events.ts +++ b/src/lib/server/ws/session-events.ts @@ -1,6 +1,5 @@ import { join } from 'node:path'; import { writeFile } from 'node:fs/promises'; -import { debug } from '../logger.js'; import { poolSend, isClientUnreachable, type PoolEntry } from './session-pool.js'; import { normalizeQuotaSnapshots } from './quota.js'; import { getSessionStateDir } from '../copilot/session-metadata.js'; @@ -8,52 +7,6 @@ import { chatStateStore } from '../chat-state-singleton.js'; import { sendPushToUser } from '../push/sender.js'; import { subscriptionStore } from '../push-singleton.js'; -export const HANDLED_EVENT_TYPES = new Set([ - 'assistant.message_delta', 'assistant.reasoning_delta', 'assistant.reasoning', - 'assistant.intent', 'assistant.turn_start', 'assistant.turn_end', 'assistant.usage', - 'tool.execution_start', 'tool.execution_complete', 'tool.execution_progress', - 'tool.execution_partial_result', - 'session.mode_changed', 'session.error', 'session.title_changed', - 'session.warning', 'session.usage_info', 'session.info', - 'session.plan_changed', 'session.compaction_start', 'session.compaction_complete', - 'session.shutdown', 'session.model_change', 'session.idle', 'session.task_complete', - 'session.truncation', 'session.context_changed', 'session.workspace_file_changed', - 'subagent.started', 'subagent.completed', 'subagent.failed', - 'subagent.selected', 'subagent.deselected', - 'skill.invoked', - 'elicitation.requested', 'elicitation.completed', - 'exit_plan_mode.requested', 'exit_plan_mode.completed', - 'system.notification', - // Phase 10: newly wired events - 'session.start', 'session.resume', - 'session.remote_steerable_changed', 'session.snapshot_rewind', - 'session.handoff', 'session.background_tasks_changed', - 'session.skills_loaded', 'session.custom_agents_updated', - 'session.mcp_server_status_changed', 'session.extensions_loaded', - 'session.mcp_servers_loaded', 'session.tools_updated', - 'abort', - 'mcp.oauth_required', 'mcp.oauth_completed', - 'sampling.requested', 'sampling.completed', - 'external_tool.requested', 'external_tool.completed', -]); - -// High-frequency or SDK-internal events safe to silently ignore -const SUPPRESSED_EVENT_TYPES = new Set([ - 'pending_messages.modified', - 'assistant.streaming_delta', - 'user.message', - 'hook.start', - 'hook.end', -]); - -export function createCatchAllHandler(entry: PoolEntry, handledTypes: Set): (event: any) => void { - return (event: any) => { - if (!handledTypes.has(event.type) && !SUPPRESSED_EVENT_TYPES.has(event.type)) { - console.log('[EVENT] unhandled SDK event:', event.type, JSON.stringify(event.data ?? {}).slice(0, 200)); - } - }; -} - export function wireSessionEvents( session: any, entry: PoolEntry, @@ -81,6 +34,7 @@ export function wireSessionEvents( session.on('assistant.turn_end', () => { entry.isProcessing = false; poolSend(entry, { type: 'turn_end' }); + poolSend(entry, { type: 'done' }); // Persist the accumulated assistant message (fire-and-forget) if (userLogin && tabId && pendingAssistantContent) { @@ -104,20 +58,15 @@ export function wireSessionEvents( pendingAssistantContent = ''; }); session.on('tool.execution_start', (event: any) => { - debug('[TOOL] execution_start:', event.data.toolName, 'mcp:', event.data.mcpServerName, '/', event.data.mcpToolName); + console.log('[TOOL] execution_start:', event.data.toolName, 'mcp:', event.data.mcpServerName, '/', event.data.mcpToolName); poolSend(entry, { type: 'tool_start', toolCallId: event.data.toolCallId, toolName: event.data.toolName, mcpServerName: event.data.mcpServerName, mcpToolName: event.data.mcpToolName }); }); session.on('tool.execution_complete', (event: any) => { - debug('[TOOL] execution_complete:', event.data.toolCallId, 'success:', event.data.success); - poolSend(entry, { - type: 'tool_end', - toolCallId: event.data.toolCallId, - success: event.data.success !== false, - ...(event.data.error?.message ? { error: event.data.error.message } : {}), - }); + console.log('[TOOL] execution_complete:', event.data.toolCallId); + poolSend(entry, { type: 'tool_end', toolCallId: event.data.toolCallId }); }); session.on('tool.execution_progress', (event: any) => { - debug('[TOOL] execution_progress:', event.data.toolCallId, event.data.message); + console.log('[TOOL] execution_progress:', event.data.toolCallId, event.data.message); poolSend(entry, { type: 'tool_progress', toolCallId: event.data.toolCallId, message: event.data.message }); }); session.on('session.mode_changed', (event: any) => { @@ -182,14 +131,7 @@ export function wireSessionEvents( poolSend(entry, { type: 'subagent_end', agentName: event.data.agentName }); }); session.on('session.info', (event: any) => { - const infoType = event.data?.infoType; - const url = event.data?.url; - // Remote session export publishes a github.com URL for monitoring/steering - if (infoType === 'remote' && typeof url === 'string') { - poolSend(entry, { type: 'remote_session_url', url, message: event.data?.message }); - return; - } - poolSend(entry, { type: 'info', message: event.data?.message || event.data, ...(infoType ? { infoType } : {}), ...(url ? { url } : {}) }); + poolSend(entry, { type: 'info', message: event.data?.message || event.data }); }); session.on('session.plan_changed', (event: any) => { poolSend(entry, { type: 'plan_changed', content: event.data?.content, path: event.data?.path }); @@ -284,8 +226,6 @@ export function wireSessionEvents( poolSend(entry, { type: 'tool_partial_result', toolCallId: event.data?.toolCallId, partialOutput: event.data?.partialOutput }); }); session.on('session.context_changed', (event: any) => { - entry.workspaceCwd = event.data?.cwd ?? entry.workspaceCwd; - entry.workspaceGitRoot = event.data?.gitRoot ?? entry.workspaceGitRoot; poolSend(entry, { type: 'context_changed', cwd: event.data?.cwd, @@ -298,69 +238,26 @@ export function wireSessionEvents( poolSend(entry, { type: 'workspace_file_changed', path: event.data?.path, operation: event.data?.operation }); }); - session.on('system.notification', (event: any) => { - poolSend(entry, { type: 'system_notification', content: event.data?.content, kind: event.data?.kind }); - }); - - // Phase 10: newly wired events - session.on('session.start', (event: any) => { - poolSend(entry, { type: 'session_start', ...event.data }); - }); - session.on('session.resume', (event: any) => { - poolSend(entry, { type: 'session_resume', ...event.data }); - }); - session.on('session.remote_steerable_changed', (event: any) => { - poolSend(entry, { type: 'remote_steerable_changed', ...event.data }); - }); - session.on('session.snapshot_rewind', (event: any) => { - poolSend(entry, { type: 'snapshot_rewind', ...event.data }); - }); - session.on('session.handoff', (event: any) => { - poolSend(entry, { type: 'session_handoff', ...event.data }); - }); - session.on('session.background_tasks_changed', (event: any) => { - poolSend(entry, { type: 'background_tasks_changed', agents: event.data?.agents }); - }); - session.on('session.skills_loaded', (event: any) => { - poolSend(entry, { type: 'skills_loaded', ...event.data }); - }); - session.on('session.custom_agents_updated', (event: any) => { - poolSend(entry, { type: 'custom_agents_updated', ...event.data }); - }); - session.on('session.mcp_server_status_changed', (event: any) => { - poolSend(entry, { type: 'mcp_server_status_changed', name: event.data?.name, status: event.data?.status, error: event.data?.error }); - }); - session.on('session.extensions_loaded', (event: any) => { - poolSend(entry, { type: 'extensions_loaded', ...event.data }); - }); - session.on('session.mcp_servers_loaded', (event: any) => { - poolSend(entry, { type: 'mcp_servers_loaded', ...event.data }); - }); - session.on('session.tools_updated', (event: any) => { - poolSend(entry, { type: 'tools_updated', ...event.data }); - }); - session.on('abort', () => { - poolSend(entry, { type: 'abort' }); - }); - session.on('mcp.oauth_required', (event: any) => { - poolSend(entry, { type: 'mcp_oauth_required', serverName: event.data?.serverName, authUrl: event.data?.authUrl }); - }); - session.on('mcp.oauth_completed', (event: any) => { - poolSend(entry, { type: 'mcp_oauth_completed', serverName: event.data?.serverName }); - }); - session.on('sampling.requested', (event: any) => { - poolSend(entry, { type: 'sampling_requested', ...event.data }); - }); - session.on('sampling.completed', (event: any) => { - poolSend(entry, { type: 'sampling_completed', ...event.data }); - }); - session.on('external_tool.requested', (event: any) => { - poolSend(entry, { type: 'external_tool_requested', ...event.data }); - }); - session.on('external_tool.completed', (event: any) => { - poolSend(entry, { type: 'external_tool_completed', ...event.data }); - }); - // Catch-all: log unhandled event types for debugging / future audit - session.on(createCatchAllHandler(entry, HANDLED_EVENT_TYPES)); + const handledTypes = new Set([ + 'assistant.message_delta', 'assistant.reasoning_delta', 'assistant.reasoning', + 'assistant.intent', 'assistant.turn_start', 'assistant.turn_end', 'assistant.usage', + 'tool.execution_start', 'tool.execution_complete', 'tool.execution_progress', + 'tool.execution_partial_result', + 'session.mode_changed', 'session.error', 'session.title_changed', + 'session.warning', 'session.usage_info', 'session.info', + 'session.plan_changed', 'session.compaction_start', 'session.compaction_complete', + 'session.shutdown', 'session.model_change', 'session.idle', 'session.task_complete', + 'session.truncation', 'session.context_changed', 'session.workspace_file_changed', + 'subagent.started', 'subagent.completed', 'subagent.failed', + 'subagent.selected', 'subagent.deselected', + 'skill.invoked', + 'elicitation.requested', 'elicitation.completed', + 'exit_plan_mode.requested', 'exit_plan_mode.completed', + ]); + session.on((event: any) => { + if (!handledTypes.has(event.type)) { + console.log('[EVENT] unhandled SDK event:', event.type, JSON.stringify(event.data ?? {}).slice(0, 200)); + } + }); } diff --git a/src/lib/server/ws/session-history.ts b/src/lib/server/ws/session-history.ts new file mode 100644 index 0000000..9020eae --- /dev/null +++ b/src/lib/server/ws/session-history.ts @@ -0,0 +1,69 @@ +import type { HistoryMessage } from './types.js'; + +let historyIdCounter = 0; +function historyId(): string { + return `hist-${Date.now()}-${historyIdCounter++}`; +} + +/** Map SDK session events from getMessages() to HistoryMessage[] for the client history. */ +export function mapSessionEventsToHistory(events: any[]): HistoryMessage[] { + const messages: HistoryMessage[] = []; + for (const event of events) { + const ts = event.timestamp ? new Date(event.timestamp).getTime() : Date.now(); + switch (event.type) { + case 'user.message': + if (event.data?.content) { + messages.push({ id: historyId(), role: 'user', content: event.data.content, timestamp: ts }); + } + break; + case 'assistant.message': + if (event.data?.content) { + messages.push({ id: historyId(), role: 'assistant', content: event.data.content, timestamp: ts }); + } + break; + case 'assistant.reasoning': + if (event.data?.content) { + messages.push({ id: historyId(), role: 'reasoning', content: event.data.content, timestamp: ts }); + } + break; + case 'assistant.intent': + if (event.data?.intent) { + messages.push({ id: historyId(), role: 'intent', content: event.data.intent, timestamp: ts }); + } + break; + case 'tool.execution_start': + messages.push({ + id: historyId(), + role: 'tool', + content: event.data?.toolName ?? 'unknown', + timestamp: ts, + toolCallId: event.data?.toolCallId, + toolName: event.data?.toolName, + toolStatus: 'complete', + mcpServerName: event.data?.mcpServerName, + mcpToolName: event.data?.mcpToolName, + }); + break; + case 'subagent.started': + if (event.data?.agentName) { + messages.push({ + id: historyId(), + role: 'subagent', + content: event.data.description ?? event.data.agentName, + timestamp: ts, + agentName: event.data.agentName, + }); + } + break; + case 'session.error': + if (event.data?.message) { + messages.push({ id: historyId(), role: 'error', content: event.data.message, timestamp: ts }); + } + break; + // Skip ephemeral/streaming events: deltas, usage, turn_start/end, idle, etc. + default: + break; + } + } + return messages; +} diff --git a/src/lib/server/ws/session-pool.test.ts b/src/lib/server/ws/session-pool.test.ts index 2b034da..ab956ad 100644 --- a/src/lib/server/ws/session-pool.test.ts +++ b/src/lib/server/ws/session-pool.test.ts @@ -169,40 +169,6 @@ describe('destroyPoolEntry', () => { await expect(destroyPoolEntry(entry)).resolves.toBeUndefined(); }); - - it('falls back to forceStop when stop() does not resolve within 5s', async () => { - vi.useFakeTimers(); - const client = createClientMock() as ClientMock & { forceStop: ReturnType }; - let resolveStop: (() => void) | null = null; - client.stop.mockImplementation( - () => new Promise((res) => { - resolveStop = res; - }), - ); - client.forceStop = vi.fn(async () => undefined); - - const entry = createPoolEntry(client as never, createWsMock() as never); - const destroyPromise = destroyPoolEntry(entry); - - // Advance past the 5s timeout window so the fallback fires. - await vi.advanceTimersByTimeAsync(5001); - await destroyPromise; - - expect(client.forceStop).toHaveBeenCalledTimes(1); - // Cleanup the still-pending stop() to avoid leaks across tests. - (resolveStop as (() => void) | null)?.(); - }); - - it('does not call forceStop when stop() resolves quickly', async () => { - const client = createClientMock() as ClientMock & { forceStop: ReturnType }; - client.forceStop = vi.fn(async () => undefined); - - const entry = createPoolEntry(client as never, createWsMock() as never); - await destroyPoolEntry(entry); - - expect(client.stop).toHaveBeenCalledTimes(1); - expect(client.forceStop).not.toHaveBeenCalled(); - }); }); describe('session pool cleanup', () => { diff --git a/src/lib/server/ws/session-pool.ts b/src/lib/server/ws/session-pool.ts index 3c8984e..316ba21 100644 --- a/src/lib/server/ws/session-pool.ts +++ b/src/lib/server/ws/session-pool.ts @@ -1,5 +1,5 @@ import { WebSocket } from 'ws'; -import type { CopilotClient, ElicitationResult } from '@github/copilot-sdk'; +import type { CopilotClient } from '@github/copilot-sdk'; import { config } from '../config.js'; const MAX_BUFFER_SIZE = 500; @@ -8,7 +8,7 @@ const TAB_ID_PATTERN = /^[a-z0-9_-]{1,64}$/i; // Control message types that should be prioritized in the buffer (never evicted before data messages) const CONTROL_MESSAGE_TYPES = new Set([ 'connected', 'cold_resume', 'session_created', 'session_resumed', 'session_reconnected', - 'turn_start', 'turn_end', 'error', 'warning', + 'turn_start', 'turn_end', 'done', 'error', 'warning', 'session_shutdown', 'mode_changed', 'model_changed', 'title_changed', 'permission_request', 'user_input_request', 'tool_start', 'tool_end', 'session_idle', 'task_complete', @@ -41,18 +41,8 @@ export interface PoolEntry { pendingUserInputPrompt: Record | null; /** Map of pending permission prompts keyed by requestId — re-sent on reconnect */ pendingPermissionPrompts: Map>; - /** Current reasoning effort level for the session */ - reasoningEffort: string | null; /** Timestamp of the last client ping — used to detect backgrounded/suspended apps */ lastPingAt: number; - /** Latest workspace cwd reported by the SDK session */ - workspaceCwd: string | null; - /** Latest git root reported by the SDK session */ - workspaceGitRoot: string | null; - /** Resolver for pending elicitation request — server-side `onElicitationRequest` promise */ - elicitationResolve: ((result: ElicitationResult) => void) | null; - /** Stored pending elicitation prompt for re-send on reconnect */ - pendingElicitationPrompt: Record | null; } export const sessionPool = new Map(); @@ -74,12 +64,7 @@ export function createPoolEntry(client: CopilotClient, ws: WebSocket): PoolEntry seq: 0, pendingUserInputPrompt: null, pendingPermissionPrompts: new Map(), - reasoningEffort: null, lastPingAt: Date.now(), - workspaceCwd: null, - workspaceGitRoot: null, - elicitationResolve: null, - pendingElicitationPrompt: null, }; } @@ -97,32 +82,7 @@ export async function destroyPoolEntry(entry: PoolEntry): Promise { entry.pendingUserInputPrompt = null; entry.pendingPermissionPrompts.clear(); entry.permissionPreferences.clear(); - // Graceful stop with 5s timeout fallback to forceStop(). - // Prevents hung CLI subprocesses if `stop()` blocks (seen in long-running deployments). - let timer: ReturnType | null = null; - let settled = false; - const stopPromise = entry.client.stop().catch(() => undefined).finally(() => { - settled = true; - if (timer) { - clearTimeout(timer); - timer = null; - } - }); - const timeoutPromise = new Promise<'timeout'>((resolve) => { - timer = setTimeout(() => resolve('timeout'), 5000); - }); - - const result = await Promise.race([stopPromise.then(() => 'ok' as const), timeoutPromise]); - if (result === 'timeout' && !settled) { - const maybeForceStop = (entry.client as { forceStop?: () => Promise }).forceStop; - if (typeof maybeForceStop === 'function') { - try { await maybeForceStop.call(entry.client); } catch { /* ignore */ } - } - } - // Always await the original stop so we don't leak an unhandled promise/timer. - if (settled) { - await stopPromise; - } + try { await entry.client.stop(); } catch { /* ignore */ } } /** True when the client WS is closed or hasn't sent a ping recently (e.g. iOS backgrounded). */ diff --git a/src/lib/server/ws/types.ts b/src/lib/server/ws/types.ts index c31fb8c..78c15a1 100644 --- a/src/lib/server/ws/types.ts +++ b/src/lib/server/ws/types.ts @@ -10,3 +10,23 @@ export interface MessageContext { poolKey: string; ws: WebSocket; } + +/** SDK attachment union – mirrors the types accepted by session.send(). */ +export type SdkAttachment = + | { type: 'file'; path: string; displayName?: string } + | { type: 'directory'; path: string; displayName?: string } + | { type: 'selection'; filePath: string; displayName: string; selection?: { start: { line: number; character: number }; end: { line: number; character: number } }; text?: string }; + +/** Minimal ChatMessage shape for session history reconstruction (mirrors src/lib/types/index.ts) */ +export interface HistoryMessage { + id: string; + role: string; + content: string; + timestamp: number; + toolCallId?: string; + toolName?: string; + toolStatus?: string; + mcpServerName?: string; + mcpToolName?: string; + agentName?: string; +} diff --git a/src/lib/stores/chat.svelte.ts b/src/lib/stores/chat.svelte.ts index 27698f1..041f6ea 100644 --- a/src/lib/stores/chat.svelte.ts +++ b/src/lib/stores/chat.svelte.ts @@ -2,20 +2,16 @@ import type { Attachment, ChatMessage, ChatMessageRole, - CopilotUsageItem, ToolCallState, - ToolCallStatus, ServerMessage, SessionMode, ReasoningEffort, ModelInfo, ToolInfo, AgentInfo, - SourcedAgentInfo, SessionSummary, SessionDetail, UserInputState, - ElicitationState, PermissionRequestState, ContextInfo, PlanState, @@ -45,18 +41,13 @@ export interface ChatStore { readonly fleetActive: boolean; readonly fleetAgents: Array<{ agentId: string; agentType: string; status: 'running' | 'completed' | 'failed'; error?: string }>; readonly sessionTitle: string | null; - /** github.com URL when the session is exported/steerable remotely */ - readonly remoteUrl: string | null; - /** True when the active session runs on GitHub's cloud agent */ - readonly isCloudSession: boolean; readonly pendingUserInput: UserInputState | null; - readonly pendingElicitation: ElicitationState | null; readonly pendingPermissions: PermissionRequestState[]; // Data lists readonly models: Map; readonly tools: ToolInfo[]; - readonly agents: SourcedAgentInfo[]; + readonly agents: (AgentInfo | string)[]; readonly sessions: SessionSummary[]; readonly sessionDetail: SessionDetail | null; @@ -80,14 +71,12 @@ export interface ChatStore { handleServerMessage(msg: ServerMessage): void; clearMessages(): void; addUserMessage(content: string, attachments?: Attachment[]): void; - addInfoMessage(content: string): void; addQueuedMessage(content: string, attachments?: Attachment[]): void; sendQueuedMessage(id: string): { content: string; attachments?: Attachment[] } | null; cancelQueuedMessage(id: string): void; flushQueue(): { content: string; attachments?: Attachment[] } | null; clearPendingPermission(requestId?: string): void; clearPendingUserInput(): void; - clearPendingElicitation(): void; } let nextId = 0; @@ -115,31 +104,17 @@ export function createChatStore(wsStore: WsStore): ChatStore { let fleetAgents = $state>([]); let sessionTitle = $state(null); let currentSessionId = $state(null); - // Remote/cloud session state - let remoteUrl = $state(null); - let isCloudSession = $state(false); let pendingUserInput = $state(null); - let pendingElicitation = $state(null); let pendingPermissions = $state([]); // Tracks if the current turn involved tool execution (tool approval → tool run). // Used to force-notify on turn_end even when the tab is visible, because the user // had to briefly return to approve the tool and may have switched away again. let hadToolExecution = false; - // Accumulates per-LLM-call usage within a turn; emitted as one summary on turn_end - let pendingUsage: { - inputTokens: number; outputTokens: number; reasoningTokens: number; - cacheReadTokens: number; cacheWriteTokens: number; - totalCost: number; totalDurationMs: number; apiCalls: number; - premiumDelta: number; - lastQuotaSnapshots: QuotaSnapshots | null; - lastCopilotUsage: CopilotUsageItem[] | null; - } | null = null; - // ── Data lists ────────────────────────────────────────────────────────── let models = $state(new Map()); let tools = $state([]); - let agents = $state([]); + let agents = $state<(AgentInfo | string)[]>([]); let sessions = $state([]); let sessionDetail = $state(null); @@ -256,44 +231,12 @@ export function createChatStore(wsStore: WsStore): ChatStore { case 'session_created': currentModel = msg.model; if (msg.sessionId) currentSessionId = msg.sessionId; - plan = { exists: false, content: '' }; - isCloudSession = false; - remoteUrl = null; - wsStore.getQuota(); - wsStore.listSessions(); - break; - - case 'cloud_session_created': { - if (msg.model) currentModel = msg.model; - if (msg.sessionId) currentSessionId = msg.sessionId; - plan = { exists: false, content: '' }; - isCloudSession = true; - remoteUrl = null; - const repo = msg.repository ? ` for ${msg.repository.owner}/${msg.repository.name}${msg.repository.branch ? `@${msg.repository.branch}` : ''}` : ''; - addInfoMessage(`Cloud session created${repo} — running on GitHub's cloud agent`); - wsStore.getQuota(); - wsStore.listSessions(); - break; - } - - case 'remote_session_url': - remoteUrl = msg.url; - addInfoMessage(msg.message || `Session available on GitHub: ${msg.url}`); - break; - - case 'remote_toggled': - if (!msg.enabled) { - remoteUrl = null; - addInfoMessage('Remote session disabled'); - } break; case 'session_reconnected': if (msg.hasSession) { addInfoMessage('Session reconnected'); } - wsStore.getQuota(); - wsStore.listSessions(); break; case 'turn_start': @@ -302,7 +245,6 @@ export function createChatStore(wsStore: WsStore): ChatStore { isWaiting = true; activeToolCalls = new Map(); hadToolExecution = false; - // Don't reset pendingUsage — accumulate across all turns in the agentic loop break; case 'reasoning_delta': @@ -328,8 +270,6 @@ export function createChatStore(wsStore: WsStore): ChatStore { case 'tool_start': isWaiting = false; hadToolExecution = true; - // Skip internal SDK tools — the intent event already provides a friendly description - if (msg.toolName === 'report_intent') break; addMessage('tool', msg.toolName, { toolCallId: msg.toolCallId, toolName: msg.toolName, @@ -354,13 +294,7 @@ export function createChatStore(wsStore: WsStore): ChatStore { case 'tool_end': messages = messages.map(m => - m.toolCallId === msg.toolCallId - ? { - ...m, - toolStatus: (msg.success === false ? 'failed' : 'complete') as ToolCallStatus, - ...(msg.success === false && msg.error ? { toolError: msg.error } : {}), - } - : m, + m.toolCallId === msg.toolCallId ? { ...m, toolStatus: 'complete' as const } : m, ); break; @@ -373,38 +307,23 @@ export function createChatStore(wsStore: WsStore): ChatStore { currentStreamContent += msg.content; break; - case 'turn_end': { - const hadContent = currentStreamContent.trim().length > 0; - finalizeStream(); - // Only notify and emit usage when the turn produced actual assistant content — - // intermediate turns (tool decisions) accumulate usage silently. - if (hadContent) { - if (!msg.replayed) { - notify('Response ready', { - body: messages.at(-1)?.content?.slice(0, 100) || undefined, - tag: 'response-ready', - force: hadToolExecution, - }); - } - // Emit one consolidated usage summary for the entire response - if (pendingUsage && pendingUsage.apiCalls > 0) { - addMessage('usage', '', { - inputTokens: pendingUsage.inputTokens, - outputTokens: pendingUsage.outputTokens, - reasoningTokens: pendingUsage.reasoningTokens || undefined, - cacheReadTokens: pendingUsage.cacheReadTokens || undefined, - cacheWriteTokens: pendingUsage.cacheWriteTokens || undefined, - duration: pendingUsage.totalDurationMs || undefined, - cost: pendingUsage.totalCost || undefined, - quotaSnapshots: pendingUsage.lastQuotaSnapshots ?? undefined, - copilotUsage: pendingUsage.lastCopilotUsage ?? undefined, - }); - } - pendingUsage = null; + case 'turn_end': + case 'done': + // Skip client-side notification for replayed messages: the server already sent a + // push notification while the client was unreachable (iOS backgrounded / WS closed). + if (!msg.replayed) { + notify('Response ready', { + body: currentStreamContent.trim().slice(0, 100) || undefined, + tag: 'response-ready', + // Force-notify after tool execution: the user may have briefly returned to + // approve a tool call and then switched away again — tab might be visible at + // the exact moment the response arrives, but they still want to know. + force: hadToolExecution, + }); } hadToolExecution = false; + finalizeStream(); break; - } case 'models': { const newModels = new Map(); @@ -426,7 +345,7 @@ export function createChatStore(wsStore: WsStore): ChatStore { autopilot: 'Auto', }; mode = msg.mode; - // State updated silently — mode pill in TopBar provides visual feedback + addInfoMessage(`Mode changed to ${modeLabels[msg.mode] ?? msg.mode}`); break; } @@ -434,7 +353,7 @@ export function createChatStore(wsStore: WsStore): ChatStore { if (msg.model) { currentModel = msg.model; } - // State updated silently — model pill in TopBar provides visual feedback + addInfoMessage(`Model changed to ${msg.model}`); break; case 'title_changed': @@ -447,7 +366,18 @@ export function createChatStore(wsStore: WsStore): ChatStore { break; case 'usage': - // Accumulate per-LLM-call usage; one summary emitted on turn_end + addMessage('usage', '', { + inputTokens: msg.inputTokens, + outputTokens: msg.outputTokens, + reasoningTokens: msg.reasoningTokens, + cacheReadTokens: msg.cacheReadTokens, + cacheWriteTokens: msg.cacheWriteTokens, + duration: msg.duration, + cost: msg.cost, + quotaSnapshots: msg.quotaSnapshots, + copilotUsage: msg.copilotUsage, + }); + // Derive premium requests from quota snapshot delta { let premiumDelta = 0; if (msg.quotaSnapshots) { @@ -455,12 +385,15 @@ export function createChatStore(wsStore: WsStore): ChatStore { const currentUsed = primary?.snapshot.usedRequests; if (currentUsed != null) { if (baselineUsedRequests == null) { + // First snapshot — capture baseline (before this turn's usage) + // Estimate baseline as currentUsed minus cost (cost ≈ premium requests consumed this call) baselineUsedRequests = currentUsed - (msg.cost ?? 0); } premiumDelta = currentUsed - baselineUsedRequests; } quotaSnapshots = msg.quotaSnapshots; } + // Also try copilotUsage as fallback const copilotPremium = msg.copilotUsage?.reduce( (acc: number, item: { premiumRequests?: number }) => acc + (item.premiumRequests ?? 0), 0, ) ?? 0; @@ -476,26 +409,6 @@ export function createChatStore(wsStore: WsStore): ChatStore { apiCalls: sessionTotals.apiCalls + 1, premiumRequests: resolvedPremium, }; - // Accumulate into pending turn usage - if (!pendingUsage) { - pendingUsage = { - inputTokens: 0, outputTokens: 0, reasoningTokens: 0, - cacheReadTokens: 0, cacheWriteTokens: 0, - totalCost: 0, totalDurationMs: 0, apiCalls: 0, premiumDelta: 0, - lastQuotaSnapshots: null, lastCopilotUsage: null, - }; - } - pendingUsage.inputTokens += msg.inputTokens ?? 0; - pendingUsage.outputTokens += msg.outputTokens ?? 0; - pendingUsage.reasoningTokens += msg.reasoningTokens ?? 0; - pendingUsage.cacheReadTokens += msg.cacheReadTokens ?? 0; - pendingUsage.cacheWriteTokens += msg.cacheWriteTokens ?? 0; - pendingUsage.totalCost += msg.cost ?? 0; - pendingUsage.totalDurationMs += msg.duration ?? 0; - pendingUsage.apiCalls += 1; - pendingUsage.premiumDelta = resolvedPremium; - if (msg.quotaSnapshots) pendingUsage.lastQuotaSnapshots = msg.quotaSnapshots; - if (msg.copilotUsage) pendingUsage.lastCopilotUsage = msg.copilotUsage; } break; @@ -521,12 +434,12 @@ export function createChatStore(wsStore: WsStore): ChatStore { isWaiting = false; currentStreamContent = ''; pendingUserInput = null; - pendingElicitation = null; pendingPermissions = []; addInfoMessage('Response stopped'); break; case 'user_input_request': + case 'elicitation_requested': pendingUserInput = { pending: true, question: msg.question, @@ -540,39 +453,8 @@ export function createChatStore(wsStore: WsStore): ChatStore { }); break; - case 'elicitation_requested': - if (msg.requestedSchema && typeof msg.requestedSchema === 'object' && 'properties' in msg.requestedSchema) { - pendingElicitation = { - elicitationId: msg.elicitationId, - message: msg.message, - requestedSchema: msg.requestedSchema as ElicitationState['requestedSchema'], - mode: msg.mode, - elicitationSource: msg.elicitationSource, - }; - notify('Copilot needs information from you', { - body: msg.message ?? 'Please fill out the form', - tag: 'elicitation', - requireInteraction: true, - }); - } else { - // Legacy fallback: treat as simple user input - pendingUserInput = { - pending: true, - question: msg.question ?? msg.message ?? '', - choices: msg.choices, - allowFreeform: msg.allowFreeform ?? true, - }; - notify('Copilot is asking you something', { - body: msg.question ?? msg.message, - tag: 'user-input', - requireInteraction: true, - }); - } - break; - case 'elicitation_completed': pendingUserInput = null; - pendingElicitation = null; break; case 'permission_request': @@ -602,6 +484,7 @@ export function createChatStore(wsStore: WsStore): ChatStore { case 'agent_changed': currentAgent = msg.agent; + addInfoMessage(msg.agent ? `Agent selected: @${msg.agent}` : 'Agent deselected'); break; case 'quota': @@ -646,7 +529,7 @@ export function createChatStore(wsStore: WsStore): ChatStore { break; case 'plan_changed': - // Automatic SDK event — no chat message needed + addInfoMessage('Plan updated'); break; case 'plan_updated': @@ -669,19 +552,27 @@ export function createChatStore(wsStore: WsStore): ChatStore { addInfoMessage('Compacting conversation…'); break; - case 'compaction_complete': - // Verbose follow-up to compaction_start — context bar shows window usage + case 'compaction_complete': { + let compactionMsg = 'Compaction complete'; if (msg.preCompactionTokens != null && msg.postCompactionTokens != null) { - // Update the existing "Compacting…" info with a brief result - addInfoMessage( - `Compacted: ${msg.preCompactionTokens.toLocaleString()} → ${msg.postCompactionTokens.toLocaleString()} tokens`, - ); + compactionMsg += `: ${msg.preCompactionTokens.toLocaleString()} → ${msg.postCompactionTokens.toLocaleString()} tokens`; + } + if (msg.tokensRemoved) { + compactionMsg += (msg.preCompactionTokens != null ? ', ' : ': ') + `removed ${msg.tokensRemoved.toLocaleString()} tokens`; + } + if (msg.messagesRemoved) { + compactionMsg += `, ${msg.messagesRemoved} messages`; } + addInfoMessage(compactionMsg); break; + } case 'compaction_result': - // Suppressed — compaction_complete already covers this - break; + addInfoMessage( + 'Compaction result' + + (msg.tokensRemoved ? `: removed ${msg.tokensRemoved} tokens` : '') + + (msg.messagesRemoved ? `, ${msg.messagesRemoved} messages` : ''), + ); break; case 'skill_invoked': @@ -780,8 +671,11 @@ export function createChatStore(wsStore: WsStore): ChatStore { break; case 'exit_plan_mode_requested': + addInfoMessage('Exiting plan mode…'); + break; + case 'exit_plan_mode_completed': - // Transient UI state — no chat message needed + addInfoMessage('Exited plan mode'); break; case 'context_info': @@ -801,34 +695,19 @@ export function createChatStore(wsStore: WsStore): ChatStore { } addInfoMessage( 'Session ended' + - (msg.totalPremiumRequests != null ? ` · ${msg.totalPremiumRequests} AIC` : '') + + (msg.totalPremiumRequests != null ? ` · ${msg.totalPremiumRequests} premium requests` : '') + (msg.totalApiDurationMs != null ? ` · ${(msg.totalApiDurationMs / 1000).toFixed(1)}s total API time` : ''), ); break; case 'reasoning_changed': reasoningEffort = msg.effort; - // State updated silently — reasoning effort selector provides visual feedback + addInfoMessage(`Reasoning effort set to ${msg.effort}`); break; case 'session_idle': isStreaming = false; isWaiting = false; - // Flush any remaining accumulated usage that wasn't emitted on turn_end - if (pendingUsage && pendingUsage.apiCalls > 0) { - addMessage('usage', '', { - inputTokens: pendingUsage.inputTokens, - outputTokens: pendingUsage.outputTokens, - reasoningTokens: pendingUsage.reasoningTokens || undefined, - cacheReadTokens: pendingUsage.cacheReadTokens || undefined, - cacheWriteTokens: pendingUsage.cacheWriteTokens || undefined, - duration: pendingUsage.totalDurationMs || undefined, - cost: pendingUsage.totalCost || undefined, - quotaSnapshots: pendingUsage.lastQuotaSnapshots ?? undefined, - copilotUsage: pendingUsage.lastCopilotUsage ?? undefined, - }); - pendingUsage = null; - } break; case 'task_complete': @@ -838,7 +717,10 @@ export function createChatStore(wsStore: WsStore): ChatStore { break; case 'truncation': - // Technical detail — context bar shows window usage + addInfoMessage( + `Context truncated: ${msg.preTruncationMessages} → ${msg.postTruncationMessages} messages` + + ` (${msg.preTruncationTokens} → ${msg.postTruncationTokens} tokens)`, + ); break; case 'tool_partial_result': @@ -853,11 +735,14 @@ export function createChatStore(wsStore: WsStore): ChatStore { break; case 'context_changed': - // State available in EnvInfo — no chat message needed + addInfoMessage( + `Context: ${msg.repository ?? msg.cwd}` + + (msg.branch ? ` (${msg.branch})` : ''), + ); break; case 'workspace_file_changed': - // Fires per file change, too spammy for chat + addInfoMessage(`Workspace file ${msg.operation}d: ${msg.path}`); break; case 'sessions_changed': @@ -883,13 +768,11 @@ export function createChatStore(wsStore: WsStore): ChatStore { sessionTitle = null; currentSessionId = null; pendingUserInput = null; - pendingElicitation = null; pendingPermissions = []; contextInfo = null; sessionDetail = null; baselineUsedRequests = null; sessionTotals = { ...emptyTotals }; - plan = { exists: false, content: '' }; // Notify server to delete persisted state wsStore.send({ type: 'clear_chat' }); @@ -911,10 +794,6 @@ export function createChatStore(wsStore: WsStore): ChatStore { pendingUserInput = null; } - function clearPendingElicitation(): void { - pendingElicitation = null; - } - // ── Queue management ────────────────────────────────────────────────── function addQueuedMessage(content: string, attachments?: Attachment[]): void { @@ -959,10 +838,7 @@ export function createChatStore(wsStore: WsStore): ChatStore { get fleetActive() { return fleetActive; }, get fleetAgents() { return fleetAgents; }, get sessionTitle() { return sessionTitle; }, - get remoteUrl() { return remoteUrl; }, - get isCloudSession() { return isCloudSession; }, get pendingUserInput() { return pendingUserInput; }, - get pendingElicitation() { return pendingElicitation; }, get pendingPermissions() { return pendingPermissions; }, get models() { return models; }, @@ -985,13 +861,11 @@ export function createChatStore(wsStore: WsStore): ChatStore { handleServerMessage, clearMessages, addUserMessage, - addInfoMessage, addQueuedMessage, sendQueuedMessage, cancelQueuedMessage, flushQueue, clearPendingPermission, clearPendingUserInput, - clearPendingElicitation, }; } diff --git a/src/lib/stores/chat.test.ts b/src/lib/stores/chat.test.ts index 6d2ee1e..e6b73c2 100644 --- a/src/lib/stores/chat.test.ts +++ b/src/lib/stores/chat.test.ts @@ -9,7 +9,6 @@ import type { SessionDetail, SessionSummary, SessionUsageTotals, - SourcedAgentInfo, ToolInfo, } from '$lib/types/index.js'; @@ -36,8 +35,6 @@ function createWsStoreMock(options: { send: vi.fn(), sendMessage: vi.fn(), newSession: vi.fn(), - newCloudSession: vi.fn(), - remoteToggle: vi.fn(), resumeSession: vi.fn(), setMode: vi.fn(), setModel: vi.fn(), @@ -57,7 +54,6 @@ function createWsStoreMock(options: { updatePlan: vi.fn(), deletePlan: vi.fn(), respondToUserInput: vi.fn(), - respondToElicitation: vi.fn(), respondToPermission: vi.fn(), }; } @@ -154,7 +150,7 @@ describe('createChatStore', () => { expect(store.canSend).toBe(true); expect(store.currentStreamContent).toBe('Hello world'); - dispatch(store, { type: 'turn_end' }); + dispatch(store, { type: 'done' }); expect(store.isStreaming).toBe(false); expect(store.isWaiting).toBe(false); @@ -212,17 +208,18 @@ describe('createChatStore', () => { dispatch(store, { type: 'delta', content: ' ' }, { type: 'turn_end' }); expect(store.messages.filter((message) => message.role === 'assistant')).toHaveLength(0); - // No notification for whitespace-only content (no actual response) + expect(notifyMock).toHaveBeenLastCalledWith('Response ready', { + body: undefined, + tag: 'response-ready', + force: true, + }); }); it('tracks model, tool, agent, and session metadata updates', () => { const store = createChatStore(createWsStoreMock()); const modelInfo: ModelInfo = { id: 'claude-sonnet', name: 'Claude Sonnet' }; const tools: ToolInfo[] = [{ name: 'bash', description: 'Run shell commands' }]; - const agents: SourcedAgentInfo[] = [ - { name: 'explore', source: 'builtin', isSelected: false }, - { name: 'reviewer', description: 'Reviews code', source: 'repo', isSelected: false }, - ]; + const agents: (AgentInfo | string)[] = ['explore', { name: 'reviewer', description: 'Reviews code' }]; const sessions: SessionSummary[] = [ { id: 'session-1', title: 'Old title' }, { id: 'session-2', title: 'Keep me' }, @@ -263,6 +260,9 @@ describe('createChatStore', () => { expect(store.sessions).toEqual([{ id: 'session-2', title: 'Keep me' }]); expect(store.sessionDetail).toEqual(detail); expect(store.messages).toEqual([ + expect.objectContaining({ role: 'info', content: 'Mode changed to Plan' }), + expect.objectContaining({ role: 'info', content: 'Model changed to gpt-5' }), + expect.objectContaining({ role: 'info', content: 'Agent selected: @reviewer' }), expect.objectContaining({ role: 'info', content: 'Session reconnected' }), expect.objectContaining({ role: 'info', content: 'Session resumed: session-2' }), expect.objectContaining({ role: 'info', content: 'Session deleted' }), @@ -283,7 +283,6 @@ describe('createChatStore', () => { dispatch( store, - { type: 'turn_start' }, { type: 'usage', inputTokens: 100, @@ -308,33 +307,33 @@ describe('createChatStore', () => { { type: 'reasoning_changed', effort: 'high' }, ); - // Usage is accumulated, not emitted per-call (emitted on turn_end with content or session_idle) - expect(store.messages.filter(m => m.role === 'usage')).toHaveLength(0); - expect(store.quotaSnapshots).toEqual(updatedQuota); - expect(store.contextInfo).toEqual({ tokenLimit: 64000, currentTokens: 4000, messagesLength: 12 }); - expect(store.plan).toEqual({ exists: true, content: '1. Investigate\n2. Ship\n3. Verify', path: '/tmp/plan.md' }); - expect(store.reasoningEffort).toBe('high'); - - // session_idle flushes remaining accumulated usage - dispatch(store, { type: 'session_idle' }); - - const usageMessages = store.messages.filter(m => m.role === 'usage'); - expect(usageMessages).toHaveLength(1); - expect(usageMessages[0]).toMatchObject({ + expect(store.messages[0]).toMatchObject({ role: 'usage', inputTokens: 100, outputTokens: 250, + reasoningTokens: 40, + cacheReadTokens: 50, + cacheWriteTokens: 10, + duration: 1200, + cost: 0.12, + quotaSnapshots: initialQuota, + copilotUsage: [{ type: 'prompt', tokens: 100 }], }); + expect(store.quotaSnapshots).toEqual(updatedQuota); + expect(store.contextInfo).toEqual({ tokenLimit: 64000, currentTokens: 4000, messagesLength: 12 }); + expect(store.plan).toEqual({ exists: true, content: '1. Investigate\n2. Ship\n3. Verify', path: '/tmp/plan.md' }); + expect(store.reasoningEffort).toBe('high'); dispatch(store, { type: 'plan_deleted' }); expect(store.plan).toEqual({ exists: false, content: '' }); - // Suppressed events: plan_changed, compaction_complete verbose, compaction_result, reasoning_changed - const infoMessages = store.messages.filter(m => m.role === 'info'); - expect(infoMessages).toEqual([ + expect(store.messages.slice(1)).toEqual([ + expect.objectContaining({ role: 'info', content: 'Plan updated' }), expect.objectContaining({ role: 'info', content: 'Plan saved' }), expect.objectContaining({ role: 'info', content: 'Compacting conversation…' }), - expect.objectContaining({ role: 'info', content: 'Compacted: 5,000 → 4,880 tokens' }), + expect.objectContaining({ role: 'info', content: 'Compaction complete: 5,000 → 4,880 tokens, removed 120 tokens, 3 messages' }), + expect.objectContaining({ role: 'info', content: 'Compaction result, 1 messages' }), + expect.objectContaining({ role: 'info', content: 'Reasoning effort set to high' }), expect.objectContaining({ role: 'info', content: 'Plan deleted' }), ]); }); @@ -418,6 +417,8 @@ describe('createChatStore', () => { expect.objectContaining({ role: 'subagent', content: 'reviewer completed', agentName: 'reviewer' }), expect.objectContaining({ role: 'error', content: 'Sub-agent triager failed: boom' }), expect.objectContaining({ role: 'info', content: 'Info' }), + expect.objectContaining({ role: 'info', content: 'Exiting plan mode…' }), + expect.objectContaining({ role: 'info', content: 'Exited plan mode' }), ]); }); @@ -504,7 +505,7 @@ describe('createChatStore', () => { totalCost: 0, totalDurationMs: 0, apiCalls: 0, premiumRequests: 0, }); - dispatch(store, { type: 'turn_start' }, { + dispatch(store, { type: 'usage', inputTokens: 100, outputTokens: 200, @@ -535,15 +536,6 @@ describe('createChatStore', () => { totalCost: 5, totalDurationMs: 1200, apiCalls: 2, premiumRequests: 0, }); - // turn_end with content emits one consolidated usage message - dispatch(store, { type: 'delta', content: 'Final answer' }, { type: 'turn_end' }); - const usageMessages = store.messages.filter(m => m.role === 'usage'); - expect(usageMessages).toHaveLength(1); - expect(usageMessages[0]).toMatchObject({ - inputTokens: 250, - outputTokens: 500, - }); - // clearMessages resets totals store.clearMessages(); expect(store.sessionTotals.apiCalls).toBe(0); @@ -554,17 +546,10 @@ describe('createChatStore', () => { const store = createChatStore(createWsStoreMock()); dispatch(store, { - type: 'turn_start', - }, { type: 'usage', inputTokens: 100, outputTokens: 200, cost: 1, - }, { - type: 'delta', - content: 'Response', - }, { - type: 'turn_end', }); dispatch(store, { @@ -577,9 +562,8 @@ describe('createChatStore', () => { expect(store.sessionTotals.premiumRequests).toBe(5); expect(store.sessionTotals.totalDurationMs).toBe(3200); expect(store.messages).toEqual([ - expect.objectContaining({ role: 'assistant', content: 'Response' }), expect.objectContaining({ role: 'usage' }), - expect.objectContaining({ role: 'info', content: 'Session ended · 5 AIC · 3.2s total API time' }), + expect.objectContaining({ role: 'info', content: 'Session ended · 5 premium requests · 3.2s total API time' }), ]); }); @@ -611,7 +595,7 @@ describe('createChatStore', () => { store.clearMessages(); - // truncation is suppressed (context bar shows window usage) + // truncation adds descriptive info message dispatch(store, { type: 'truncation', tokenLimit: 128000, @@ -620,17 +604,25 @@ describe('createChatStore', () => { postTruncationTokens: 60000, postTruncationMessages: 30, }); - expect(store.messages).toEqual([]); + expect(store.messages).toEqual([ + expect.objectContaining({ + role: 'info', + content: 'Context truncated: 50 → 30 messages (100000 → 60000 tokens)', + }), + ]); store.clearMessages(); - // context_changed and workspace_file_changed are suppressed + // context_changed and workspace_file_changed produce info messages dispatch( store, { type: 'context_changed', cwd: '/tmp', gitRoot: '/tmp', repository: 'o/r', branch: 'main' }, { type: 'workspace_file_changed', path: 'plan.md', operation: 'update' }, ); - expect(store.messages).toEqual([]); + expect(store.messages).toEqual([ + expect.objectContaining({ role: 'info', content: 'Context: o/r (main)' }), + expect.objectContaining({ role: 'info', content: 'Workspace file updated: plan.md' }), + ]); store.clearMessages(); diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index cf47ba3..3b5b503 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -2,11 +2,11 @@ import type { SessionMode, ReasoningEffort, PersistedSettings, - SourcedSkillInfo, - SourcedMcpServerInfo, + CustomToolDefinition, + CustomAgentDefinition, + McpServerDefinition, + SkillDefinition, InfiniteSessionsConfig, - InstructionInfo, - PromptInfo, } from '$lib/types/index.js'; const STORAGE_KEY = 'copilot-cli-settings'; @@ -21,72 +21,96 @@ const DEFAULT_SETTINGS: PersistedSettings = { model: '', mode: 'interactive', reasoningEffort: 'medium', - additionalInstructions: '', + customInstructions: '', excludedTools: [], + customTools: [], + customAgents: [], + mcpServers: [], + disabledSkills: [], infiniteSessions: { ...DEFAULT_INFINITE_SESSIONS }, notificationsEnabled: false, - voiceInputEnabled: true, - ttsEnabled: true, - ttsRate: 1.0, - remoteSession: 'off', }; -const VALID_REMOTE_SESSION = new Set>(['off', 'export', 'on']); - const VALID_MODES = new Set(['interactive', 'plan', 'autopilot']); const VALID_REASONING = new Set(['low', 'medium', 'high', 'xhigh']); +function isValidCustomTool(t: unknown): t is CustomToolDefinition { + if (!t || typeof t !== 'object') return false; + const obj = t as Record; + return ( + typeof obj.name === 'string' && + typeof obj.description === 'string' && + typeof obj.webhookUrl === 'string' && + (obj.method === 'GET' || obj.method === 'POST') && + typeof obj.headers === 'object' && obj.headers !== null && + typeof obj.parameters === 'object' && obj.parameters !== null + ); +} + +function isValidCustomAgent(agent: unknown): agent is CustomAgentDefinition { + if (!agent || typeof agent !== 'object') return false; + const obj = agent as Record; + return ( + typeof obj.name === 'string' && + (typeof obj.displayName === 'undefined' || typeof obj.displayName === 'string') && + (typeof obj.description === 'undefined' || typeof obj.description === 'string') && + (typeof obj.tools === 'undefined' || (Array.isArray(obj.tools) && obj.tools.every((tool) => typeof tool === 'string'))) && + typeof obj.prompt === 'string' + ); +} + +function isValidMcpServer(s: unknown): s is McpServerDefinition { + if (!s || typeof s !== 'object') return false; + const obj = s as Record; + if ( + typeof obj.name !== 'string' || + typeof obj.url !== 'string' || + (obj.type !== 'http' && obj.type !== 'sse') || + typeof obj.headers !== 'object' || obj.headers === null || + !Array.isArray(obj.tools) || + typeof obj.enabled !== 'boolean' + ) return false; + if ('timeout' in obj && obj.timeout !== undefined) { + if (typeof obj.timeout !== 'number' || obj.timeout <= 0) return false; + } + return true; +} + export interface SettingsStore { - additionalInstructions: string; + customInstructions: string; excludedTools: string[]; + customTools: CustomToolDefinition[]; + customAgents: CustomAgentDefinition[]; reasoningEffort: ReasoningEffort; selectedModel: string; selectedMode: SessionMode; - discoveredMcpServers: SourcedMcpServerInfo[]; - availableSkills: SourcedSkillInfo[]; - extensions: Array<{ name: string; description?: string; enabled: boolean }>; - instructions: InstructionInfo[]; - prompts: PromptInfo[]; + mcpServers: McpServerDefinition[]; + disabledSkills: string[]; + availableSkills: SkillDefinition[]; infiniteSessions: InfiniteSessionsConfig; notificationsEnabled: boolean; - voiceInputEnabled: boolean; - ttsEnabled: boolean; - ttsRate: number; - remoteSession: NonNullable; load(): void; save(): void; syncFromServer(): Promise; fetchSkills(): Promise; - fetchCustomizations(): Promise; } export function createSettingsStore(): SettingsStore { - let additionalInstructions = $state(DEFAULT_SETTINGS.additionalInstructions); + let customInstructions = $state(DEFAULT_SETTINGS.customInstructions); let excludedTools = $state([...DEFAULT_SETTINGS.excludedTools]); + let customTools = $state([...DEFAULT_SETTINGS.customTools]); + let customAgents = $state([...(DEFAULT_SETTINGS.customAgents ?? [])]); let reasoningEffort = $state(DEFAULT_SETTINGS.reasoningEffort); let selectedModel = $state(DEFAULT_SETTINGS.model); let selectedMode = $state(DEFAULT_SETTINGS.mode); - let availableSkills = $state([]); - let discoveredMcpServers = $state([]); - let extensions = $state>([]); - let instructions = $state([]); - let prompts = $state([]); + let mcpServers = $state([...(DEFAULT_SETTINGS.mcpServers ?? [])]); + let disabledSkills = $state([...(DEFAULT_SETTINGS.disabledSkills ?? [])]); + let availableSkills = $state([]); let infiniteSessions = $state({ ...DEFAULT_INFINITE_SESSIONS }); let notificationsEnabled = $state(DEFAULT_SETTINGS.notificationsEnabled ?? false); - let voiceInputEnabled = $state(DEFAULT_SETTINGS.voiceInputEnabled ?? true); - let ttsEnabled = $state(DEFAULT_SETTINGS.ttsEnabled ?? true); - let ttsRate = $state(DEFAULT_SETTINGS.ttsRate ?? 1.0); - let remoteSession = $state>(DEFAULT_SETTINGS.remoteSession ?? 'off'); - - // Detect a usable browser localStorage. Node 25+ exposes a built-in - // `localStorage` global as a stub when `--localstorage-file` is not set; - // the stub lacks `.getItem`/`.setItem` so `typeof localStorage === 'undefined'` - // is no longer a reliable SSR guard — check `window` instead. - const hasLocalStorage = (): boolean => - typeof window !== 'undefined' && typeof window.localStorage?.getItem === 'function'; function load(): void { - if (!hasLocalStorage()) return; + if (typeof localStorage === 'undefined') return; try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; @@ -102,14 +126,14 @@ export function createSettingsStore(): SettingsStore { model: selectedModel, mode: selectedMode, reasoningEffort, - additionalInstructions, + customInstructions, excludedTools, + customTools, + customAgents, + mcpServers, + disabledSkills, infiniteSessions, notificationsEnabled, - voiceInputEnabled, - ttsEnabled, - ttsRate, - remoteSession, }; } @@ -121,14 +145,24 @@ export function createSettingsStore(): SettingsStore { if (parsed.reasoningEffort && VALID_REASONING.has(parsed.reasoningEffort as ReasoningEffort)) { reasoningEffort = parsed.reasoningEffort as ReasoningEffort; } - // Accept both new and legacy field names for backward compatibility - const instructions = parsed.additionalInstructions ?? (parsed as Record).customInstructions; - if (typeof instructions === 'string') { - additionalInstructions = instructions; + if (typeof parsed.customInstructions === 'string') { + customInstructions = parsed.customInstructions; } if (Array.isArray(parsed.excludedTools)) { excludedTools = parsed.excludedTools.filter((t): t is string => typeof t === 'string'); } + if (Array.isArray(parsed.customTools)) { + customTools = parsed.customTools.filter(isValidCustomTool).slice(0, 10); + } + if (Array.isArray(parsed.customAgents)) { + customAgents = parsed.customAgents.filter(isValidCustomAgent).slice(0, 10); + } + if (Array.isArray(parsed.mcpServers)) { + mcpServers = parsed.mcpServers.filter(isValidMcpServer).slice(0, 10); + } + if (Array.isArray(parsed.disabledSkills)) { + disabledSkills = parsed.disabledSkills.filter((s): s is string => typeof s === 'string'); + } if (parsed.infiniteSessions && typeof parsed.infiniteSessions === 'object') { const is = parsed.infiniteSessions; infiniteSessions = { @@ -144,25 +178,10 @@ export function createSettingsStore(): SettingsStore { if (typeof parsed.notificationsEnabled === 'boolean') { notificationsEnabled = parsed.notificationsEnabled; } - if (typeof parsed.voiceInputEnabled === 'boolean') { - voiceInputEnabled = parsed.voiceInputEnabled; - } - if (typeof parsed.ttsEnabled === 'boolean') { - ttsEnabled = parsed.ttsEnabled; - } - if (typeof parsed.ttsRate === 'number') { - ttsRate = Math.max(0.5, Math.min(2, parsed.ttsRate)); - } - if (parsed.remoteSession && VALID_REMOTE_SESSION.has(parsed.remoteSession)) { - remoteSession = parsed.remoteSession; - } } function save(): void { - if (!hasLocalStorage()) { - syncToServer(); - return; - } + if (typeof localStorage === 'undefined') return; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(getCurrentData())); } catch { @@ -192,7 +211,7 @@ export function createSettingsStore(): SettingsStore { if (body.settings) { applySettings(body.settings); // Update localStorage with server data - if (hasLocalStorage()) { + if (typeof localStorage !== 'undefined') { localStorage.setItem(STORAGE_KEY, JSON.stringify(getCurrentData())); } } else { @@ -208,7 +227,7 @@ export function createSettingsStore(): SettingsStore { try { const res = await fetch('/api/skills'); if (!res.ok) return; - const body = await res.json() as { skills?: SourcedSkillInfo[] }; + const body = await res.json() as { skills?: SkillDefinition[] }; if (Array.isArray(body.skills)) { availableSkills = body.skills; } @@ -217,36 +236,19 @@ export function createSettingsStore(): SettingsStore { } } - async function fetchCustomizations(): Promise { - try { - const res = await fetch('/api/customizations'); - if (!res.ok) return; - const body = await res.json() as { - instructions?: InstructionInfo[]; - prompts?: PromptInfo[]; - mcpServers?: SourcedMcpServerInfo[]; - }; - if (Array.isArray(body.instructions)) { - instructions = body.instructions; - } - if (Array.isArray(body.prompts)) { - prompts = body.prompts; - } - if (Array.isArray(body.mcpServers)) { - discoveredMcpServers = body.mcpServers; - } - } catch { - // Ignore fetch errors - } - } - return { - get additionalInstructions() { return additionalInstructions; }, - set additionalInstructions(v: string) { additionalInstructions = v; save(); }, + get customInstructions() { return customInstructions; }, + set customInstructions(v: string) { customInstructions = v; save(); }, get excludedTools() { return excludedTools; }, set excludedTools(v: string[]) { excludedTools = v; save(); }, + get customTools() { return customTools; }, + set customTools(v: CustomToolDefinition[]) { customTools = v.slice(0, 10); save(); }, + + get customAgents() { return customAgents; }, + set customAgents(v: CustomAgentDefinition[]) { customAgents = v.slice(0, 10); save(); }, + get reasoningEffort() { return reasoningEffort; }, set reasoningEffort(v: ReasoningEffort) { reasoningEffort = v; save(); }, @@ -256,20 +258,14 @@ export function createSettingsStore(): SettingsStore { get selectedMode() { return selectedMode; }, set selectedMode(v: SessionMode) { selectedMode = v; save(); }, - get availableSkills() { return availableSkills; }, - set availableSkills(v: SourcedSkillInfo[]) { availableSkills = v; }, - - get extensions() { return extensions; }, - set extensions(v: Array<{ name: string; description?: string; enabled: boolean }>) { extensions = v; }, - - get discoveredMcpServers() { return discoveredMcpServers; }, - set discoveredMcpServers(v: SourcedMcpServerInfo[]) { discoveredMcpServers = v; }, + get mcpServers() { return mcpServers; }, + set mcpServers(v: McpServerDefinition[]) { mcpServers = v.slice(0, 10); save(); }, - get instructions() { return instructions; }, - set instructions(v: InstructionInfo[]) { instructions = v; }, + get disabledSkills() { return disabledSkills; }, + set disabledSkills(v: string[]) { disabledSkills = v; save(); }, - get prompts() { return prompts; }, - set prompts(v: PromptInfo[]) { prompts = v; }, + get availableSkills() { return availableSkills; }, + set availableSkills(v: SkillDefinition[]) { availableSkills = v; }, get infiniteSessions() { return infiniteSessions; }, set infiniteSessions(v: InfiniteSessionsConfig) { @@ -288,25 +284,9 @@ export function createSettingsStore(): SettingsStore { get notificationsEnabled() { return notificationsEnabled; }, set notificationsEnabled(v: boolean) { notificationsEnabled = v; save(); }, - get voiceInputEnabled() { return voiceInputEnabled; }, - set voiceInputEnabled(v: boolean) { voiceInputEnabled = v; save(); }, - - get ttsEnabled() { return ttsEnabled; }, - set ttsEnabled(v: boolean) { ttsEnabled = v; save(); }, - - get ttsRate() { return ttsRate; }, - set ttsRate(v: number) { ttsRate = Math.max(0.5, Math.min(2, v)); save(); }, - - get remoteSession() { return remoteSession; }, - set remoteSession(v: NonNullable) { - remoteSession = VALID_REMOTE_SESSION.has(v) ? v : 'off'; - save(); - }, - load, save, syncFromServer, fetchSkills, - fetchCustomizations, }; } diff --git a/src/lib/stores/settings.test.ts b/src/lib/stores/settings.test.ts index e0f3143..96d233d 100644 --- a/src/lib/stores/settings.test.ts +++ b/src/lib/stores/settings.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { createSettingsStore } from '$lib/stores/settings.svelte.js'; import type { + CustomAgentDefinition, + CustomToolDefinition, + McpServerDefinition, PersistedSettings, } from '$lib/types/index.js'; @@ -13,6 +16,40 @@ function jsonResponse(data: unknown, ok = true): Response { } as unknown as Response; } +function makeCustomTool(name: string): CustomToolDefinition { + return { + name, + description: `${name} description`, + webhookUrl: `https://example.com/${name}`, + method: 'POST', + headers: { Authorization: 'Bearer token' }, + parameters: { + prompt: { type: 'string', description: 'Prompt text' }, + }, + }; +} + +function makeCustomAgent(name: string): CustomAgentDefinition { + return { + name, + displayName: `${name} display`, + description: `${name} description`, + tools: [`${name}.tool`], + prompt: `Prompt for ${name}`, + }; +} + +function makeMcpServer(name: string): McpServerDefinition { + return { + name, + url: `https://mcp.example.com/${name}`, + type: 'http', + headers: { Authorization: 'Bearer token' }, + tools: [`${name}.tool`], + enabled: true, + }; +} + describe('createSettingsStore', () => { const fetchMock = vi.fn(); @@ -28,29 +65,44 @@ describe('createSettingsStore', () => { expect(store.selectedModel).toBe(''); expect(store.selectedMode).toBe('interactive'); expect(store.reasoningEffort).toBe('medium'); - expect(store.additionalInstructions).toBe(''); + expect(store.customInstructions).toBe(''); expect(store.excludedTools).toEqual([]); + expect(store.customTools).toEqual([]); + expect(store.customAgents).toEqual([]); + expect(store.mcpServers).toEqual([]); }); it('persists setter updates to localStorage and syncs them to the server', () => { const store = createSettingsStore(); + const tools = Array.from({ length: 12 }, (_, index) => makeCustomTool(`tool-${index}`)); + const agents = Array.from({ length: 12 }, (_, index) => makeCustomAgent(`agent-${index}`)); + const servers = Array.from({ length: 12 }, (_, index) => makeMcpServer(`server-${index}`)); store.selectedModel = 'gpt-5'; store.selectedMode = 'autopilot'; store.reasoningEffort = 'high'; - store.additionalInstructions = 'Be concise'; + store.customInstructions = 'Be concise'; store.excludedTools = ['bash', 'grep']; + store.customTools = tools; + store.customAgents = agents; + store.mcpServers = servers; const persisted = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as PersistedSettings; + expect(store.customTools).toHaveLength(10); + expect(store.customAgents).toHaveLength(10); + expect(store.mcpServers).toHaveLength(10); expect(persisted).toMatchObject({ model: 'gpt-5', mode: 'autopilot', reasoningEffort: 'high', - additionalInstructions: 'Be concise', + customInstructions: 'Be concise', excludedTools: ['bash', 'grep'], }); - expect(fetchMock).toHaveBeenCalledTimes(5); + expect(persisted.customTools).toHaveLength(10); + expect(persisted.customAgents).toHaveLength(10); + expect(persisted.mcpServers).toHaveLength(10); + expect(fetchMock).toHaveBeenCalledTimes(8); expect(fetchMock).toHaveBeenLastCalledWith('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -58,27 +110,54 @@ describe('createSettingsStore', () => { }); }); - it('loads legacy customInstructions field for backward compatibility', () => { - localStorage.setItem( - STORAGE_KEY, - JSON.stringify({ - customInstructions: 'legacy instructions', - }), - ); - const store = createSettingsStore(); - store.load(); - expect(store.additionalInstructions).toBe('legacy instructions'); + it('persists custom agents in settings', () => { + const settings = createSettingsStore(); + + settings.customAgents = [ + { + name: 'researcher', + prompt: 'You are a research assistant', + description: 'Research agent', + }, + ]; + settings.save(); + + const settings2 = createSettingsStore(); + settings2.load(); + + expect(settings2.customAgents).toHaveLength(1); + expect(settings2.customAgents[0].name).toBe('researcher'); + expect(settings2.customAgents[0].prompt).toBe('You are a research assistant'); }); it('loads valid persisted settings, filters invalid entries, and keeps mode interactive', () => { + const validTool = makeCustomTool('valid-tool'); + const validAgent = makeCustomAgent('valid-agent'); + const validServer = makeMcpServer('valid-server'); + localStorage.setItem( STORAGE_KEY, JSON.stringify({ model: 'claude-sonnet', mode: 'autopilot', reasoningEffort: 'xhigh', - additionalInstructions: 'Use the docs', + customInstructions: 'Use the docs', excludedTools: ['bash', 42, null], + customTools: [ + validTool, + { ...validTool, name: 123 }, + ...Array.from({ length: 10 }, (_, index) => makeCustomTool(`extra-tool-${index}`)), + ], + customAgents: [ + validAgent, + { ...validAgent, prompt: 123 }, + ...Array.from({ length: 10 }, (_, index) => makeCustomAgent(`extra-agent-${index}`)), + ], + mcpServers: [ + validServer, + { ...validServer, enabled: 'yes' }, + ...Array.from({ length: 10 }, (_, index) => makeMcpServer(`extra-server-${index}`)), + ], }), ); @@ -88,8 +167,14 @@ describe('createSettingsStore', () => { expect(store.selectedModel).toBe('claude-sonnet'); expect(store.selectedMode).toBe('interactive'); expect(store.reasoningEffort).toBe('xhigh'); - expect(store.additionalInstructions).toBe('Use the docs'); + expect(store.customInstructions).toBe('Use the docs'); expect(store.excludedTools).toEqual(['bash']); + expect(store.customTools).toHaveLength(10); + expect(store.customTools[0]).toEqual(validTool); + expect(store.customAgents).toHaveLength(10); + expect(store.customAgents[0]).toEqual(validAgent); + expect(store.mcpServers).toHaveLength(10); + expect(store.mcpServers[0]).toEqual(validServer); }); it('ignores corrupt localStorage payloads', () => { @@ -110,8 +195,11 @@ describe('createSettingsStore', () => { model: 'gpt-4.1', mode: 'plan', reasoningEffort: 'low', - additionalInstructions: 'Server wins', + customInstructions: 'Server wins', excludedTools: ['bash'], + customTools: [makeCustomTool('server-tool')], + customAgents: [makeCustomAgent('server-agent')], + mcpServers: [makeMcpServer('server-mcp')], }, }), ); @@ -123,8 +211,11 @@ describe('createSettingsStore', () => { expect(store.selectedModel).toBe('gpt-4.1'); expect(store.selectedMode).toBe('interactive'); expect(store.reasoningEffort).toBe('low'); - expect(store.additionalInstructions).toBe('Server wins'); + expect(store.customInstructions).toBe('Server wins'); expect(store.excludedTools).toEqual(['bash']); + expect(store.customTools).toEqual([makeCustomTool('server-tool')]); + expect(store.customAgents).toEqual([makeCustomAgent('server-agent')]); + expect(store.mcpServers).toEqual([makeMcpServer('server-mcp')]); const persisted = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as PersistedSettings; expect(persisted.mode).toBe('interactive'); @@ -148,14 +239,14 @@ describe('createSettingsStore', () => { model: 'gpt-4o-mini', mode: 'interactive', reasoningEffort: 'medium', - additionalInstructions: '', + customInstructions: '', excludedTools: [], + customTools: [], + customAgents: [], + mcpServers: [], + disabledSkills: [], infiniteSessions: { enabled: true, backgroundThreshold: 0.80, bufferThreshold: 0.95 }, notificationsEnabled: false, - voiceInputEnabled: true, - ttsEnabled: true, - ttsRate: 1, - remoteSession: 'off', }, }), }); diff --git a/src/lib/stores/tts.svelte.ts b/src/lib/stores/tts.svelte.ts deleted file mode 100644 index 36b5932..0000000 --- a/src/lib/stores/tts.svelte.ts +++ /dev/null @@ -1,205 +0,0 @@ -const SUPPORTED = typeof window !== 'undefined' && 'speechSynthesis' in window; - -/** - * Remove angle brackets entirely so no HTML tag can survive or be re-formed - * from adjacent fragments (fixes incomplete multi-character sanitization — - * a single-pass tag-stripping regex can be bypassed with nested markers). - */ -function stripHtmlTags(text: string): string { - return text.replace(/[<>]/g, ''); -} - -/** Strip markdown/HTML to plain text suitable for speech synthesis. */ -function stripToPlainText(markdown: string): string { - return stripHtmlTags( - markdown - // Remove code blocks (``` ... ```) - .replace(/```[\s\S]*?```/g, ' code block omitted ') - // Remove inline code - .replace(/`([^`]+)`/g, '$1') - // Remove images - .replace(/!\[([^\]]*)\]\([^)]+\)/g, '$1') - // Remove links — keep text - .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') - // Remove headings markers - .replace(/^#{1,6}\s+/gm, '') - // Remove bold/italic markers - .replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1') - .replace(/_{1,3}([^_]+)_{1,3}/g, '$1') - // Remove strikethrough - .replace(/~~([^~]+)~~/g, '$1') - // Remove blockquotes - .replace(/^>\s+/gm, '') - // Remove horizontal rules - .replace(/^[-*_]{3,}\s*$/gm, '') - ) - // Collapse multiple newlines/spaces - .replace(/\n{2,}/g, '. ') - .replace(/\n/g, ' ') - .replace(/\s{2,}/g, ' ') - .trim(); -} - -/** Split text into sentence-sized chunks for smoother TTS. */ -function splitIntoChunks(text: string): string[] { - // Split on sentence boundaries, keeping punctuation - const sentences = text.match(/[^.!?]+[.!?]+[\s]*/g); - if (!sentences) return text.length > 0 ? [text] : []; - - const chunks: string[] = []; - let current = ''; - - for (const sentence of sentences) { - // Keep chunks under ~200 chars for responsive playback - if (current.length + sentence.length > 200 && current.length > 0) { - chunks.push(current.trim()); - current = ''; - } - current += sentence; - } - if (current.trim()) { - chunks.push(current.trim()); - } - return chunks; -} - -export interface TtsStore { - readonly supported: boolean; - readonly playingMessageId: string | null; - readonly isPaused: boolean; - readonly isPlaying: boolean; - readonly autoRead: boolean; - rate: number; - voice: string; - play(messageId: string, content: string): void; - pause(): void; - resume(): void; - stop(): void; - toggle(messageId: string, content: string): void; - setAutoRead(enabled: boolean): void; -} - -export function createTtsStore(): TtsStore { - let playingMessageId = $state(null); - let isPaused = $state(false); - let rate = $state(1.0); - let voiceName = $state(''); - let autoRead = $state(false); - let chunks: string[] = []; - let chunkIndex = 0; - - function getVoice(): SpeechSynthesisVoice | null { - if (!SUPPORTED) return null; - const voices = speechSynthesis.getVoices(); - if (voiceName) { - return voices.find(v => v.name === voiceName) ?? voices[0] ?? null; - } - // Prefer a local English voice - return voices.find(v => v.lang.startsWith(navigator.language) && v.localService) - ?? voices.find(v => v.lang.startsWith('en') && v.localService) - ?? voices[0] - ?? null; - } - - function speakChunk() { - if (chunkIndex >= chunks.length) { - playingMessageId = null; - isPaused = false; - return; - } - - const utterance = new SpeechSynthesisUtterance(chunks[chunkIndex]); - utterance.rate = rate; - const voice = getVoice(); - if (voice) utterance.voice = voice; - - utterance.onend = () => { - chunkIndex++; - if (playingMessageId && !isPaused) { - speakChunk(); - } - }; - - utterance.onerror = (event) => { - if (event.error !== 'canceled' && event.error !== 'interrupted') { - console.warn('[TTS] utterance error:', event.error); - } - playingMessageId = null; - isPaused = false; - }; - - speechSynthesis.speak(utterance); - } - - function play(messageId: string, content: string) { - if (!SUPPORTED) return; - - // Stop any current playback - speechSynthesis.cancel(); - - const plainText = stripToPlainText(content); - if (!plainText) return; - - chunks = splitIntoChunks(plainText); - chunkIndex = 0; - playingMessageId = messageId; - isPaused = false; - - speakChunk(); - } - - function pause() { - if (!SUPPORTED || !playingMessageId) return; - speechSynthesis.pause(); - isPaused = true; - } - - function resume() { - if (!SUPPORTED || !playingMessageId) return; - speechSynthesis.resume(); - isPaused = false; - } - - function stop() { - if (!SUPPORTED) return; - speechSynthesis.cancel(); - playingMessageId = null; - isPaused = false; - chunks = []; - chunkIndex = 0; - } - - function toggle(messageId: string, content: string) { - if (playingMessageId === messageId) { - if (isPaused) { - resume(); - } else { - stop(); - } - } else { - play(messageId, content); - } - } - - return { - get supported() { return SUPPORTED; }, - get playingMessageId() { return playingMessageId; }, - get isPaused() { return isPaused; }, - get isPlaying() { return !!playingMessageId && !isPaused; }, - get autoRead() { return autoRead; }, - - get rate() { return rate; }, - set rate(v: number) { rate = Math.max(0.5, Math.min(2, v)); }, - - get voice() { return voiceName; }, - set voice(v: string) { voiceName = v; }, - - play, - pause, - resume, - stop, - toggle, - - setAutoRead(enabled: boolean) { autoRead = enabled; }, - }; -} diff --git a/src/lib/stores/tts.test.ts b/src/lib/stores/tts.test.ts deleted file mode 100644 index 18cc669..0000000 --- a/src/lib/stores/tts.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Mock SpeechSynthesis API -let lastUtterance: { - text: string; - rate: number; - voice: unknown; - onend: (() => void) | null; - onerror: ((e: { error: string }) => void) | null; -} | null = null; - -const mockSpeechSynthesis = { - speak: vi.fn((utterance: typeof lastUtterance) => { - lastUtterance = utterance; - }), - cancel: vi.fn(() => { - lastUtterance = null; - }), - pause: vi.fn(), - resume: vi.fn(), - getVoices: vi.fn(() => [ - { name: 'English Voice', lang: 'en-US', localService: true } as SpeechSynthesisVoice, - { name: 'Italian Voice', lang: 'it-IT', localService: true } as SpeechSynthesisVoice, - ]), -}; - -class MockSpeechSynthesisUtterance { - text: string; - rate = 1; - voice: SpeechSynthesisVoice | null = null; - onend: (() => void) | null = null; - onerror: ((e: { error: string }) => void) | null = null; - - constructor(text: string) { - this.text = text; - } -} - -describe('TTS Store', () => { - beforeEach(() => { - vi.stubGlobal('speechSynthesis', mockSpeechSynthesis); - vi.stubGlobal('SpeechSynthesisUtterance', MockSpeechSynthesisUtterance); - mockSpeechSynthesis.speak.mockClear(); - mockSpeechSynthesis.cancel.mockClear(); - mockSpeechSynthesis.pause.mockClear(); - mockSpeechSynthesis.resume.mockClear(); - lastUtterance = null; - }); - - it('stripToPlainText removes markdown formatting', async () => { - // Dynamically import to get the module - const mod = await import('$lib/stores/tts.svelte.js'); - const store = mod.createTtsStore(); - - // Verify store is created successfully - expect(store).toBeDefined(); - expect(store.playingMessageId).toBeNull(); - expect(store.isPaused).toBe(false); - expect(store.isPlaying).toBe(false); - }); - - it('rate is clamped between 0.5 and 2.0', async () => { - const { createTtsStore } = await import('$lib/stores/tts.svelte.js'); - const store = createTtsStore(); - - store.rate = 3.0; - expect(store.rate).toBe(2.0); - - store.rate = 0.1; - expect(store.rate).toBe(0.5); - - store.rate = 1.5; - expect(store.rate).toBe(1.5); - }); - - it('toggle starts playback for a new message', async () => { - const { createTtsStore } = await import('$lib/stores/tts.svelte.js'); - const store = createTtsStore(); - - store.toggle('msg-1', 'Hello world'); - expect(mockSpeechSynthesis.cancel).toHaveBeenCalled(); - expect(mockSpeechSynthesis.speak).toHaveBeenCalled(); - expect(store.playingMessageId).toBe('msg-1'); - }); - - it('toggle stops playback for the same message', async () => { - const { createTtsStore } = await import('$lib/stores/tts.svelte.js'); - const store = createTtsStore(); - - store.toggle('msg-1', 'Hello world'); - expect(store.playingMessageId).toBe('msg-1'); - - store.toggle('msg-1', 'Hello world'); - expect(store.playingMessageId).toBeNull(); - }); - - it('stop cancels speech and resets state', async () => { - const { createTtsStore } = await import('$lib/stores/tts.svelte.js'); - const store = createTtsStore(); - - store.play('msg-1', 'Hello world'); - expect(store.playingMessageId).toBe('msg-1'); - - store.stop(); - expect(mockSpeechSynthesis.cancel).toHaveBeenCalled(); - expect(store.playingMessageId).toBeNull(); - expect(store.isPaused).toBe(false); - }); - - it('play with empty content is a no-op', async () => { - const { createTtsStore } = await import('$lib/stores/tts.svelte.js'); - const store = createTtsStore(); - - store.play('msg-1', ''); - expect(store.playingMessageId).toBeNull(); - }); - - it('play strips markdown before speaking', async () => { - const { createTtsStore } = await import('$lib/stores/tts.svelte.js'); - const store = createTtsStore(); - - store.play('msg-1', '**bold** and `code`'); - expect(mockSpeechSynthesis.speak).toHaveBeenCalled(); - // The utterance text should not contain markdown - const call = mockSpeechSynthesis.speak.mock.calls[0][0] as MockSpeechSynthesisUtterance; - expect(call.text).not.toContain('**'); - expect(call.text).not.toContain('`'); - expect(call.text).toContain('bold'); - expect(call.text).toContain('code'); - }); - - it('code blocks are replaced with placeholder', async () => { - const { createTtsStore } = await import('$lib/stores/tts.svelte.js'); - const store = createTtsStore(); - - store.play('msg-1', 'Here is some code:\n```js\nconst x = 1;\n```\nEnd.'); - const call = mockSpeechSynthesis.speak.mock.calls[0][0] as MockSpeechSynthesisUtterance; - expect(call.text).toContain('code block omitted'); - expect(call.text).not.toContain('const x = 1'); - }); -}); diff --git a/src/lib/stores/ws.svelte.ts b/src/lib/stores/ws.svelte.ts index 3892492..4f0e2f8 100644 --- a/src/lib/stores/ws.svelte.ts +++ b/src/lib/stores/ws.svelte.ts @@ -6,8 +6,8 @@ import type { ClientMessage, ServerMessage, NewSessionConfig, - CloudSessionConfig, MessageDeliveryMode, + McpServerDefinition, } from '$lib/types/index.js'; import { notify } from '$lib/utils/notifications.js'; import { getPushSubscription } from '$lib/utils/push-notifications.js'; @@ -27,13 +27,7 @@ const HEARTBEAT_TIMEOUT = 5_000; // enabled, but that only causes collisions when the SAME browser is used on both // devices. Different browsers (e.g. Edge on desktop, Safari PWA on iOS) have // independent localStorage and are unaffected. -// Node 25+ exposes a stub `localStorage` global without `--localstorage-file`, -// so `typeof localStorage !== 'undefined'` is no longer a valid SSR guard. -// Check `window.localStorage` with a real method instead. -const hasBrowserLocalStorage = - typeof window !== 'undefined' && typeof window.localStorage?.getItem === 'function'; - -const TAB_ID = hasBrowserLocalStorage +const TAB_ID = typeof localStorage !== 'undefined' ? localStorage.getItem('copilot-tab-id') ?? (() => { const id = crypto.randomUUID(); localStorage.setItem('copilot-tab-id', id); @@ -58,9 +52,7 @@ export interface WsStore { mode?: MessageDeliveryMode, ): void; newSession(config: NewSessionConfig): void; - newCloudSession(config: CloudSessionConfig): void; - remoteToggle(mode?: 'off' | 'export' | 'on'): void; - resumeSession(sessionId: string, options?: { silent?: boolean }): void; + resumeSession(sessionId: string, mcpServers?: McpServerDefinition[]): void; setMode(mode: SessionMode): void; setModel(model: string): void; setReasoning(effort: ReasoningEffort): void; @@ -79,7 +71,6 @@ export interface WsStore { updatePlan(content: string): void; deletePlan(): void; respondToUserInput(answer: string, wasFreeform: boolean): void; - respondToElicitation(action: 'accept' | 'decline' | 'cancel', elicitationId?: string, content?: Record): void; respondToPermission(requestId: string, kind: string, toolName: string, decision: 'allow' | 'deny' | 'always_allow' | 'always_deny'): void; } @@ -405,32 +396,25 @@ export function createWsStore(): WsStore { ...(config.reasoningEffort && { reasoningEffort: config.reasoningEffort }), ...(config.customInstructions?.trim() && { customInstructions: config.customInstructions.trim() }), ...(config.excludedTools?.length && { excludedTools: config.excludedTools }), + ...(config.customTools?.length && { customTools: config.customTools }), + ...(config.customAgents?.length && { customAgents: config.customAgents }), + ...(config.mcpServers?.length && { mcpServers: config.mcpServers }), + ...(config.disabledSkills?.length && { disabledSkills: config.disabledSkills }), ...(config.infiniteSessions && { infiniteSessions: config.infiniteSessions }), - ...(config.remoteSession && config.remoteSession !== 'off' && { remoteSession: config.remoteSession }), }; send(msg); } - function newCloudSession(config: CloudSessionConfig): void { + function resumeSession(sessionId: string, mcpServers?: McpServerDefinition[]): void { sessionReady = false; + const enabledServers = mcpServers?.filter(s => s.enabled); send({ - type: 'new_cloud_session', - ...(config.model && { model: config.model }), - ...(config.mode && { mode: config.mode }), - ...(config.reasoningEffort && { reasoningEffort: config.reasoningEffort }), - ...(config.repository && { repository: config.repository }), + type: 'resume_session', + sessionId, + ...(enabledServers?.length && { mcpServers: enabledServers }), }); } - function remoteToggle(mode?: 'off' | 'export' | 'on'): void { - send({ type: 'remote_toggle', ...(mode ? { mode } : {}) }); - } - - function resumeSession(sessionId: string, options?: { silent?: boolean }): void { - sessionReady = false; - send({ type: 'resume_session', sessionId, ...(options?.silent ? { silent: true } : {}) }); - } - function setMode(mode: SessionMode): void { send({ type: 'set_mode', mode }); } @@ -506,10 +490,6 @@ export function createWsStore(): WsStore { send({ type: 'user_input_response', answer, wasFreeform }); } - function respondToElicitation(action: 'accept' | 'decline' | 'cancel', elicitationId?: string, content?: Record): void { - send({ type: 'elicitation_response', action, ...(elicitationId ? { elicitationId } : {}), ...(content ? { content } : {}) }); - } - function respondToPermission(requestId: string, kind: string, toolName: string, decision: 'allow' | 'deny' | 'always_allow' | 'always_deny'): void { send({ type: 'permission_response', requestId, kind, toolName, decision }); } @@ -527,8 +507,6 @@ export function createWsStore(): WsStore { send, sendMessage, newSession, - newCloudSession, - remoteToggle, resumeSession, setMode, setModel, @@ -548,7 +526,6 @@ export function createWsStore(): WsStore { updatePlan, deletePlan, respondToUserInput, - respondToElicitation, respondToPermission, }; } diff --git a/src/lib/stores/ws.test.ts b/src/lib/stores/ws.test.ts index b01202d..446031d 100644 --- a/src/lib/stores/ws.test.ts +++ b/src/lib/stores/ws.test.ts @@ -58,6 +58,26 @@ describe('createWsStore', () => { })); }); + it('passes custom agents when creating a new session', () => { + const store = createWsStore(); + store.connect(); + const customAgents = [ + { name: 'researcher', prompt: 'Research the codebase', description: 'Research agent' }, + ]; + + store.newSession({ + model: 'gpt-4.1', + customAgents, + }); + + expect(sockets).toHaveLength(1); + expect(sockets[0].send).toHaveBeenCalledWith(JSON.stringify({ + type: 'new_session', + model: 'gpt-4.1', + customAgents, + })); + }); + describe('reconnection resilience', () => { it('debounces rapid connect calls within 500ms', () => { const store = createWsStore(); diff --git a/src/lib/types/attachments.ts b/src/lib/types/attachments.ts deleted file mode 100644 index 8a2056f..0000000 --- a/src/lib/types/attachments.ts +++ /dev/null @@ -1,27 +0,0 @@ -export interface FileAttachment { - path: string; - name: string; - size: number; - type: string; -} - -export interface BlobAttachment { - type: 'blob'; - data: string; - mimeType: string; -} - -export type Attachment = - | { type: 'file'; path: string; name: string; displayName?: string } - | { type: 'directory'; path: string; name: string; displayName?: string } - | { - type: 'selection'; - filePath: string; - name: string; - displayName: string; - selection?: { - start: { line: number; character: number }; - end: { line: number; character: number }; - }; - text?: string; - }; diff --git a/src/lib/types/chat.ts b/src/lib/types/chat.ts deleted file mode 100644 index a56d3aa..0000000 --- a/src/lib/types/chat.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { QuotaSnapshots } from './quota.js'; -import type { Attachment } from './attachments.js'; - -export type ChatMessageRole = - | 'user' - | 'assistant' - | 'tool' - | 'info' - | 'warning' - | 'error' - | 'intent' - | 'usage' - | 'skill' - | 'subagent' - | 'fleet' - | 'reasoning' - | 'queued'; - -export interface CopilotUsageItem { - type: string; - model?: string; - tokens?: number; - premiumRequests?: number; -} - -export interface ChatMessage { - id: string; - role: ChatMessageRole; - content: string; - timestamp: number; - toolCallId?: string; - toolName?: string; - toolStatus?: ToolCallStatus; - toolError?: string; - toolProgressMessage?: string; - toolProgressMessages?: string[]; - mcpServerName?: string; - mcpToolName?: string; - agentName?: string; - skillName?: string; - fleetAgents?: Array<{ agentId: string; agentType: string; status: 'running' | 'completed' | 'failed'; error?: string }>; - inputTokens?: number; - outputTokens?: number; - reasoningTokens?: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; - duration?: number; - cost?: number; - quotaSnapshots?: QuotaSnapshots; - copilotUsage?: CopilotUsageItem[]; - attachments?: Attachment[]; -} - -export type ToolCallStatus = 'running' | 'progress' | 'complete' | 'failed'; - -export interface ToolCallState { - id: string; - name: string; - mcpServerName?: string; - mcpToolName?: string; - status: ToolCallStatus; - message?: string; - /** Error message when status is "failed" */ - error?: string; - progressMessages?: string[]; -} diff --git a/src/lib/types/client-messages.ts b/src/lib/types/client-messages.ts deleted file mode 100644 index 5540539..0000000 --- a/src/lib/types/client-messages.ts +++ /dev/null @@ -1,293 +0,0 @@ -import type { SessionMode, ReasoningEffort } from './common.js'; -import type { Attachment } from './attachments.js'; -import type { InfiniteSessionsConfig, SystemPromptSectionInput, ModelCapabilitiesOverride } from './config.js'; - -export type MessageDeliveryMode = 'immediate' | 'enqueue'; - -export interface NewSessionMessage { - type: 'new_session'; - /** Omitted → the SDK picks its default model */ - model?: string; - mode?: SessionMode; - reasoningEffort?: ReasoningEffort; - customInstructions?: string; - excludedTools?: string[]; - infiniteSessions?: InfiniteSessionsConfig; - systemPromptSections?: Record; - modelCapabilities?: ModelCapabilitiesOverride; - enableConfigDiscovery?: boolean; - /** "off" local only, "export" publish events to GitHub, "on" export + remote steering */ - remoteSession?: 'off' | 'export' | 'on'; -} - -/** Creates a session running on GitHub's cloud agent infrastructure */ -export interface NewCloudSessionMessage { - type: 'new_cloud_session'; - model?: string; - mode?: SessionMode; - reasoningEffort?: ReasoningEffort; - repository?: { owner: string; name: string; branch?: string }; -} - -/** Toggles remote export/steering on the active session */ -export interface RemoteToggleMessage { - type: 'remote_toggle'; - mode?: 'off' | 'export' | 'on'; -} - -export interface SendMessage { - type: 'message'; - content: string; - attachments?: Attachment[]; - mode?: MessageDeliveryMode; -} - -export interface ListModelsMessage { - type: 'list_models'; -} - -export interface SetModeMessage { - type: 'set_mode'; - mode: SessionMode; -} - -export interface AbortClientMessage { - type: 'abort'; -} - -export interface SetModelMessage { - type: 'set_model'; - model: string; - modelCapabilities?: ModelCapabilitiesOverride; -} - -export interface SetReasoningMessage { - type: 'set_reasoning'; - effort: ReasoningEffort; -} - -export interface UserInputResponseMessage { - type: 'user_input_response'; - answer: string; - wasFreeform: boolean; -} - -export interface PermissionResponseMessage { - type: 'permission_response'; - requestId: string; - kind: string; - toolName: string; - decision: 'allow' | 'deny' | 'always_allow' | 'always_deny'; -} - -export interface ElicitationResponseMessage { - type: 'elicitation_response'; - action: 'accept' | 'decline' | 'cancel'; - elicitationId?: string; - content?: Record; -} - -export interface ListToolsMessage { - type: 'list_tools'; - model?: string; -} - -export interface ListAgentsMessage { - type: 'list_agents'; -} - -export interface SelectAgentMessage { - type: 'select_agent'; - name: string; -} - -export interface DeselectAgentMessage { - type: 'deselect_agent'; -} - -export interface GetQuotaMessage { - type: 'get_quota'; -} - -export interface CompactMessage { - type: 'compact'; -} - -export interface ListSessionsMessage { - type: 'list_sessions'; -} - -export interface ResumeSessionMessage { - type: 'resume_session'; - sessionId: string; - /** Suppress the SDK resume event for silent re-attach (auto cold-resume) */ - silent?: boolean; -} - -export interface DeleteSessionMessage { - type: 'delete_session'; - sessionId: string; -} - -export interface GetSessionDetailMessage { - type: 'get_session_detail'; - sessionId: string; -} - -export interface GetPlanMessage { - type: 'get_plan'; -} - -export interface UpdatePlanMessage { - type: 'update_plan'; - content: string; -} - -export interface StartFleetMessage { - type: 'start_fleet'; - prompt: string; -} - -export interface DeletePlanMessage { - type: 'delete_plan'; -} - -export interface ClearChatMessage { - type: 'clear_chat'; -} - -export interface GetSessionHistoryMessage { - type: 'get_session_history'; - sessionId?: string; -} - -export interface SessionLogMessage { - type: 'session_log'; - message: string; - level?: 'info' | 'warning' | 'error'; -} - -export interface ListSkillsRpcMessage { - type: 'list_skills_rpc'; -} - -export interface ToggleSkillRpcMessage { - type: 'toggle_skill_rpc'; - name: string; - enabled: boolean; -} - -export interface ReloadSkillsMessage { - type: 'reload_skills'; -} - -export interface ListMcpRpcMessage { - type: 'list_mcp_rpc'; -} - -export interface ToggleMcpRpcMessage { - type: 'toggle_mcp_rpc'; - name: string; - enabled: boolean; -} - -export interface ListInstructionsMessage { - type: 'list_instructions'; -} - -export interface ListPromptsMessage { - type: 'list_prompts'; -} - -export interface UsePromptMessage { - type: 'use_prompt'; - name: string; -} - -export interface ListExtensionsMessage { - type: 'list_extensions'; -} - -export interface ToggleExtensionMessage { - type: 'toggle_extension'; - name: string; - enabled: boolean; -} - -export interface ReloadExtensionsMessage { - type: 'reload_extensions'; -} - -export interface ShellExecMessage { - type: 'shell_exec'; - command: string; - cwd?: string; - timeout?: number; -} - -export interface ShellKillMessage { - type: 'shell_kill'; - pid: number; -} - -export interface WorkspaceListFilesMessage { - type: 'workspace_list_files'; -} - -export interface WorkspaceReadFileMessage { - type: 'workspace_read_file'; - path: string; -} - -export interface WorkspaceCreateFileMessage { - type: 'workspace_create_file'; - path: string; - content: string; -} - -export type ClientMessage = - | NewSessionMessage - | NewCloudSessionMessage - | RemoteToggleMessage - | SendMessage - | ListModelsMessage - | SetModeMessage - | AbortClientMessage - | SetModelMessage - | SetReasoningMessage - | UserInputResponseMessage - | PermissionResponseMessage - | ElicitationResponseMessage - | ListToolsMessage - | ListAgentsMessage - | SelectAgentMessage - | DeselectAgentMessage - | GetQuotaMessage - | CompactMessage - | ListSessionsMessage - | ResumeSessionMessage - | DeleteSessionMessage - | GetSessionDetailMessage - | GetPlanMessage - | UpdatePlanMessage - | DeletePlanMessage - | StartFleetMessage - | ClearChatMessage - | GetSessionHistoryMessage - | SessionLogMessage - | ListSkillsRpcMessage - | ToggleSkillRpcMessage - | ReloadSkillsMessage - | ListMcpRpcMessage - | ToggleMcpRpcMessage - | ListInstructionsMessage - | ListPromptsMessage - | UsePromptMessage - | ShellExecMessage - | ShellKillMessage - | WorkspaceListFilesMessage - | WorkspaceReadFileMessage - | WorkspaceCreateFileMessage - | ListExtensionsMessage - | ToggleExtensionMessage - | ReloadExtensionsMessage; diff --git a/src/lib/types/common.ts b/src/lib/types/common.ts deleted file mode 100644 index c0b3066..0000000 --- a/src/lib/types/common.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type SessionMode = 'interactive' | 'plan' | 'autopilot'; -export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh'; -export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'error'; diff --git a/src/lib/types/config.ts b/src/lib/types/config.ts deleted file mode 100644 index d5cc4a3..0000000 --- a/src/lib/types/config.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { SessionMode, ReasoningEffort } from './common.js'; -import type { ModelCapabilitiesOverride } from '@github/copilot-sdk'; - -export interface InfiniteSessionsConfig { - enabled: boolean; - backgroundThreshold: number; - bufferThreshold: number; -} - -export type { SystemMessageSection, SectionOverride, SectionOverrideAction } from '@github/copilot-sdk'; -export type { ModelCapabilitiesOverride } from '@github/copilot-sdk'; - -export interface SystemPromptSectionInput { - action: 'replace' | 'remove' | 'append' | 'prepend'; - content?: string; -} - -/** "off" local only, "export" publish events to GitHub, "on" export + remote steering */ -export type RemoteSessionMode = 'off' | 'export' | 'on'; - -export interface NewSessionConfig { - /** Omitted → the SDK picks its default model */ - model?: string; - mode?: SessionMode; - reasoningEffort?: ReasoningEffort; - customInstructions?: string; - excludedTools?: string[]; - infiniteSessions?: InfiniteSessionsConfig; - systemPromptSections?: Record; - modelCapabilities?: ModelCapabilitiesOverride; - enableConfigDiscovery?: boolean; - /** "off" local only, "export" publish events to GitHub, "on" export + remote steering */ - remoteSession?: RemoteSessionMode; -} - -export interface CloudSessionConfig { - model?: string; - mode?: SessionMode; - reasoningEffort?: ReasoningEffort; - repository?: { owner: string; name: string; branch?: string }; -} - -export interface PersistedSettings { - model: string; - mode: SessionMode; - reasoningEffort: ReasoningEffort; - additionalInstructions: string; - excludedTools: string[]; - infiniteSessions?: InfiniteSessionsConfig; - /** User preference for push notifications — persisted so it survives redeploys. */ - notificationsEnabled?: boolean; - /** User preference for voice input — show/hide the microphone button. */ - voiceInputEnabled?: boolean; - /** User preference for text-to-speech — show/hide the read aloud button. */ - ttsEnabled?: boolean; - /** TTS speech rate (0.5 to 2.0). */ - ttsRate?: number; - /** - * Per-user default for cloud/remote session publishing. - * - "off": local-only (default), no remote visibility. - * - "export": stream events to GitHub for monitor-only view on github.com/Mobile. - * - "on": full remote monitor + steer via github.com/Mobile. - * The active client only honors this when ENABLE_REMOTE_SESSIONS is enabled server-side. - */ - remoteSession?: RemoteSessionMode; -} - -export interface CustomAgentDefinition { - name: string; - displayName?: string; - description?: string; - tools?: string[]; - prompt: string; -} - -export interface SkillDefinition { - name: string; - description: string; - directory: string; - license?: string; - allowedTools?: string; -} diff --git a/src/lib/types/customizations.ts b/src/lib/types/customizations.ts deleted file mode 100644 index c87aca7..0000000 --- a/src/lib/types/customizations.ts +++ /dev/null @@ -1,44 +0,0 @@ -export type CustomizationSource = 'builtin' | 'user' | 'repo'; - -export interface SourcedAgentInfo { - name: string; - displayName?: string; - description?: string; - source: CustomizationSource; - isSelected: boolean; -} - -export interface SourcedSkillInfo { - name: string; - description: string; - source: CustomizationSource; - enabled: boolean; - userInvocable?: boolean; - path?: string; -} - -export interface SourcedMcpServerInfo { - name: string; - source: CustomizationSource; - status: string; - type?: string; - url?: string; - command?: string; - error?: string; -} - -export interface InstructionInfo { - name: string; - source: CustomizationSource; - path: string; - description?: string; - applyTo?: string; -} - -export interface PromptInfo { - name: string; - source: CustomizationSource; - path: string; - description: string; - content: string; -} diff --git a/src/lib/types/index.js b/src/lib/types/index.js new file mode 100644 index 0000000..ffd62c5 --- /dev/null +++ b/src/lib/types/index.js @@ -0,0 +1,22 @@ +// ─── Shared enums & constants ─────────────────────────────────────────────── +/** Priority order for picking the most relevant quota snapshot */ +const QUOTA_PRIORITY = ['copilot_premium', 'premium_requests', 'premium_interactions']; +/** Pick the most relevant quota snapshot: premium types first, then any other key */ +export function pickPrimaryQuota(snapshots) { + if (!snapshots) + return null; + const keys = Object.keys(snapshots); + if (keys.length === 0) + return null; + for (const k of QUOTA_PRIORITY) { + if (snapshots[k]) + return { key: k, label: formatQuotaLabel(k), snapshot: snapshots[k] }; + } + // Fallback: first available key + const k = keys[0]; + return { key: k, label: formatQuotaLabel(k), snapshot: snapshots[k] }; +} +function formatQuotaLabel(key) { + return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); +} +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/src/lib/types/index.js.map b/src/lib/types/index.js.map new file mode 100644 index 0000000..0505ec9 --- /dev/null +++ b/src/lib/types/index.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AA4E/E,kEAAkE;AAClE,MAAM,cAAc,GAAG,CAAC,iBAAiB,EAAE,kBAAkB,EAAE,sBAAsB,CAAU,CAAC;AAEhG,qFAAqF;AACrF,MAAM,UAAU,gBAAgB,CAAC,SAAgC;IAC/D,IAAI,CAAC,SAAS;QAAE,OAAO,IAAI,CAAC;IAC5B,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IACpC,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAEnC,KAAK,MAAM,CAAC,IAAI,cAAc,EAAE,CAAC;QAC/B,IAAI,SAAS,CAAC,CAAC,CAAC;YAAE,OAAO,EAAE,GAAG,EAAE,CAAC,EAAE,KAAK,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC;IAC1F,CAAC;IACD,gCAAgC;IAChC,MAAM,CAAC,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,OAAO,EAAE,GAAG,EAAE,CAAC,EAAE,KAAK,EAAE,gBAAgB,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC;AACxE,CAAC;AAED,SAAS,gBAAgB,CAAC,GAAW;IACnC,OAAO,GAAG,CAAC,OAAO,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;AACvE,CAAC"} \ No newline at end of file diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 0315cfa..ff936fc 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -1,13 +1,974 @@ -// Barrel re-export — all consumers import from '$lib/types/index.js' -export * from './common.js'; -export * from './models.js'; -export * from './tools.js'; -export * from './quota.js'; -export * from './sessions.js'; -export * from './attachments.js'; -export * from './chat.js'; -export * from './state.js'; -export * from './config.js'; -export * from './customizations.js'; -export * from './server-messages.js'; -export * from './client-messages.js'; +// ─── Shared enums & constants ─────────────────────────────────────────────── + +export type SessionMode = 'interactive' | 'plan' | 'autopilot'; +export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh'; +export type ConnectionState = 'connecting' | 'connected' | 'disconnected' | 'reconnecting' | 'error'; + +// ─── Model types ──────────────────────────────────────────────────────────── + +export interface ModelCapabilities { + limits?: { + max_context_window_tokens?: number; + max_prompt_tokens?: number; + }; + supports?: { + vision?: boolean; + reasoningEffort?: boolean; + }; +} + +export interface ModelInfo { + id: string; + name: string; + billing?: { multiplier: number }; + capabilities?: ModelCapabilities; + defaultReasoningEffort?: ReasoningEffort; + supportedReasoningEfforts?: string[]; +} + +// ─── Custom tool definitions ──────────────────────────────────────────────── + +export interface CustomToolDefinition { + name: string; + description: string; + webhookUrl: string; + method: 'GET' | 'POST'; + headers: Record; + parameters: Record; +} + +// ─── Tool / Agent types ───────────────────────────────────────────────────── + +export interface McpServerDefinition { + name: string; + url: string; + type: 'http' | 'sse'; + headers: Record; + tools: string[]; + enabled: boolean; + timeout?: number; +} + +export interface ToolInfo { + name: string; + namespacedName?: string; + description?: string; + mcpServerName?: string; +} + +export interface AgentInfo { + name: string; + description?: string; +} + +// ─── Quota types ──────────────────────────────────────────────────────────── + +export interface QuotaSnapshot { + remainingPercentage?: number; + percentageUsed?: number; + resetDate?: string; + usedRequests?: number; + entitlementRequests?: number; + overage?: number; + isUnlimitedEntitlement?: boolean; +} + +export type QuotaSnapshots = Record; + +/** Priority order for picking the most relevant quota snapshot */ +const QUOTA_PRIORITY = ['copilot_premium', 'premium_requests', 'premium_interactions'] as const; + +/** Pick the most relevant quota snapshot: premium types first, then any other key */ +export function pickPrimaryQuota(snapshots: QuotaSnapshots | null): { key: string; label: string; snapshot: QuotaSnapshot } | null { + if (!snapshots) return null; + const keys = Object.keys(snapshots); + if (keys.length === 0) return null; + + for (const k of QUOTA_PRIORITY) { + if (snapshots[k]) return { key: k, label: formatQuotaLabel(k), snapshot: snapshots[k] }; + } + // Fallback: first available key + const k = keys[0]; + return { key: k, label: formatQuotaLabel(k), snapshot: snapshots[k] }; +} + +function formatQuotaLabel(key: string): string { + return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); +} + +// ─── Session list types ───────────────────────────────────────────────────── + +export interface SessionSummary { + id: string; + title?: string; + model?: string; + updatedAt?: string; + cwd?: string; + repository?: string; + branch?: string; + checkpointCount?: number; + hasPlan?: boolean; + isRemote?: boolean; + /** Where the session was found: 'sdk' = indexed by Copilot CLI, 'filesystem' = on-disk only (bundled) */ + source?: 'sdk' | 'filesystem'; +} + +export interface CheckpointEntry { + number: number; + title: string; + filename: string; +} + +export interface SessionDetail { + id: string; + cwd?: string; + repository?: string; + branch?: string; + summary?: string; + createdAt?: string; + updatedAt?: string; + checkpoints: CheckpointEntry[]; + plan?: string; + isRemote?: boolean; +} + +// ─── Incoming server messages (discriminated union on `type`) ──────────────── + +export interface ConnectedMessage { + type: 'connected'; + user: string; + sdkSessionId?: string | null; + hasPersistedState?: boolean; +} + +export interface ColdResumeMessage { + type: 'cold_resume'; + messages: Array>; + model: string; + mode: string; + sdkSessionId: string | null; +} + +export interface SessionCreatedMessage { + type: 'session_created'; + model: string; + sessionId?: string; +} + +export interface SessionReconnectedMessage { + type: 'session_reconnected'; + user: string; + hasSession: boolean; + isProcessing?: boolean; +} + +export interface TurnStartMessage { + type: 'turn_start'; +} + +export interface DeltaMessage { + type: 'delta'; + content: string; +} + +export interface TurnEndMessage { + type: 'turn_end'; + /** True when this message was replayed from the server buffer after reconnecting. */ + replayed?: boolean; +} + +export interface DoneMessage { + type: 'done'; + /** True when this message was replayed from the server buffer after reconnecting. */ + replayed?: boolean; +} + +export interface ReasoningDeltaMessage { + type: 'reasoning_delta'; + content: string; + reasoningId: string; +} + +export interface ReasoningDoneMessage { + type: 'reasoning_done'; + reasoningId: string; + content?: string; +} + +export interface IntentMessage { + type: 'intent'; + intent: string; +} + +export interface ToolStartMessage { + type: 'tool_start'; + toolCallId: string; + toolName: string; + mcpServerName?: string; + mcpToolName?: string; +} + +export interface ToolProgressMessage { + type: 'tool_progress'; + toolCallId: string; + message: string; +} + +export interface ToolEndMessage { + type: 'tool_end'; + toolCallId: string; +} + +export interface ModelsMessage { + type: 'models'; + models: (ModelInfo | string)[]; +} + +export interface ModeChangedMessage { + type: 'mode_changed'; + mode: SessionMode; +} + +export interface ModelChangedMessage { + type: 'model_changed'; + model: string; + source?: 'sdk' | string; +} + +export interface TitleChangedMessage { + type: 'title_changed'; + title: string; +} + +export interface CopilotUsageItem { + type: string; + model?: string; + tokens?: number; + premiumRequests?: number; +} + +export interface UsageMessage { + type: 'usage'; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + reasoningTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + duration?: number; + cost?: number; + quotaSnapshots?: QuotaSnapshots; + copilotUsage?: CopilotUsageItem[]; +} + +export interface WarningMessage { + type: 'warning'; + message: string; +} + +export interface ErrorMessage { + type: 'error'; + message: string; +} + +export interface AbortedMessage { + type: 'aborted'; +} + +export interface UserInputRequestMessage { + type: 'user_input_request'; + question: string; + choices?: string[]; + allowFreeform: boolean; +} + +export interface PermissionRequestMessage { + type: 'permission_request'; + requestId: string; + kind: string; + toolName: string; + toolArgs: Record; +} + +export interface ToolsMessage { + type: 'tools'; + tools: ToolInfo[]; +} + +export interface AgentsMessage { + type: 'agents'; + agents: (AgentInfo | string)[]; + current: string | null; +} + +export interface AgentChangedMessage { + type: 'agent_changed'; + agent: string | null; +} + +export interface QuotaMessage { + type: 'quota'; + quotaSnapshots?: QuotaSnapshots; +} + +export interface SessionsMessage { + type: 'sessions'; + sessions: SessionSummary[]; +} + +export interface SessionDetailMessage { + type: 'session_detail'; + detail: SessionDetail; +} + +export interface SessionHistoryMessage { + type: 'session_history'; + messages: ChatMessage[]; +} + +export interface SessionResumedMessage { + type: 'session_resumed'; + sessionId: string; +} + +export interface SessionDeletedMessage { + type: 'session_deleted'; + sessionId: string; +} + +export interface PlanMessage { + type: 'plan'; + exists: boolean; + content?: string; + path?: string; +} + +export interface PlanChangedMessage { + type: 'plan_changed'; + content?: string; + path?: string; +} + +export interface PlanUpdatedMessage { + type: 'plan_updated'; + content?: string; + path?: string; +} + +export interface PlanDeletedMessage { + type: 'plan_deleted'; +} + +export interface CompactionStartMessage { + type: 'compaction_start'; +} + +export interface CompactionCompleteMessage { + type: 'compaction_complete'; + tokensRemoved?: number; + messagesRemoved?: number; + preCompactionTokens?: number; + postCompactionTokens?: number; +} + +export interface CompactionResultMessage { + type: 'compaction_result'; + tokensRemoved?: number; + messagesRemoved?: number; +} + +export interface SkillInvokedMessage { + type: 'skill_invoked'; + skillName: string; +} + +export interface SubagentStartMessage { + type: 'subagent_start'; + agentName: string; + description?: string; +} + +export interface SubagentEndMessage { + type: 'subagent_end'; + agentName: string; +} + +export interface SubagentFailedMessage { + type: 'subagent_failed'; + agentName?: string; + error?: string; +} + +export interface SubagentSelectedMessage { + type: 'subagent_selected'; + agentName: string; +} + +export interface SubagentDeselectedMessage { + type: 'subagent_deselected'; + agentName?: string; +} + +export interface InfoMessage { + type: 'info'; + message: string; +} + +export interface ElicitationRequestedMessage { + type: 'elicitation_requested'; + question: string; + choices?: string[]; + allowFreeform: boolean; +} + +export interface ElicitationCompletedMessage { + type: 'elicitation_completed'; + answer?: string; +} + +export interface ExitPlanModeRequestedMessage { + type: 'exit_plan_mode_requested'; +} + +export interface ExitPlanModeCompletedMessage { + type: 'exit_plan_mode_completed'; +} + +export interface ContextInfoMessage { + type: 'context_info'; + tokenLimit: number; + currentTokens: number; + messagesLength: number; +} + +export interface ReasoningChangedMessage { + type: 'reasoning_changed'; + effort: ReasoningEffort; +} + +export interface SessionShutdownMessage { + type: 'session_shutdown'; + totalPremiumRequests?: number; + totalApiDurationMs?: number; + sessionStartTime?: string; +} + +export interface SessionIdleMessage { + type: 'session_idle'; + backgroundTasks?: { + agents: Array<{ agentId: string; agentType: string }>; + }; +} + +export interface TaskCompleteMessage { + type: 'task_complete'; + summary?: string; +} + +export interface TruncationMessage { + type: 'truncation'; + tokenLimit: number; + preTruncationTokens: number; + preTruncationMessages: number; + postTruncationTokens: number; + postTruncationMessages: number; +} + +export interface ToolPartialResultMessage { + type: 'tool_partial_result'; + toolCallId: string; + partialOutput: string; +} + +export interface ContextChangedMessage { + type: 'context_changed'; + cwd: string; + gitRoot?: string; + repository?: string; + branch?: string; +} + +export interface WorkspaceFileChangedMessage { + type: 'workspace_file_changed'; + path: string; + operation: 'create' | 'update'; +} + +export interface FleetStartedMessage { + type: 'fleet_started'; + started: boolean; +} + +export interface FleetStatusMessage { + type: 'fleet_status'; + agents: Array<{ agentId: string; agentType: string }>; +} + +export interface HookPreToolMessage { + type: 'hook_pre_tool'; + toolName: string; + toolArgs?: unknown; +} + +export interface HookPostToolMessage { + type: 'hook_post_tool'; + toolName: string; + toolArgs?: unknown; +} + +export interface HookSessionStartMessage { + type: 'hook_session_start'; + source: string; +} + +export interface HookSessionEndMessage { + type: 'hook_session_end'; + reason: string; +} + +export interface HookUserPromptMessage { + type: 'hook_user_prompt'; + prompt: string; +} + +export interface HookErrorMessage { + type: 'hook_error'; + error: string; + errorContext: string; + recoverable: boolean; +} + +export type HookMessage = + | HookPreToolMessage + | HookPostToolMessage + | HookUserPromptMessage + | HookSessionStartMessage + | HookSessionEndMessage + | HookErrorMessage; + +export interface SessionUsageTotals { + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + totalCost: number; + totalDurationMs: number; + apiCalls: number; + premiumRequests: number; +} + +/** Server heartbeat response to client-side ping */ +export interface PongMessage { + type: 'pong'; +} + +export type ServerMessage = + | ConnectedMessage + | ColdResumeMessage + | SessionCreatedMessage + | SessionReconnectedMessage + | TurnStartMessage + | DeltaMessage + | TurnEndMessage + | DoneMessage + | ReasoningDeltaMessage + | ReasoningDoneMessage + | IntentMessage + | ToolStartMessage + | ToolProgressMessage + | ToolEndMessage + | ModelsMessage + | ModeChangedMessage + | ModelChangedMessage + | TitleChangedMessage + | UsageMessage + | WarningMessage + | ErrorMessage + | AbortedMessage + | UserInputRequestMessage + | PermissionRequestMessage + | ToolsMessage + | AgentsMessage + | AgentChangedMessage + | QuotaMessage + | SessionsMessage + | SessionDetailMessage + | SessionHistoryMessage + | SessionResumedMessage + | SessionDeletedMessage + | PlanMessage + | PlanChangedMessage + | PlanUpdatedMessage + | PlanDeletedMessage + | CompactionStartMessage + | CompactionCompleteMessage + | CompactionResultMessage + | SkillInvokedMessage + | SubagentStartMessage + | SubagentEndMessage + | SubagentFailedMessage + | SubagentSelectedMessage + | SubagentDeselectedMessage + | InfoMessage + | ElicitationRequestedMessage + | ElicitationCompletedMessage + | ExitPlanModeRequestedMessage + | ExitPlanModeCompletedMessage + | ContextInfoMessage + | ReasoningChangedMessage + | SessionShutdownMessage + | SessionIdleMessage + | TaskCompleteMessage + | TruncationMessage + | ToolPartialResultMessage + | ContextChangedMessage + | WorkspaceFileChangedMessage + | FleetStartedMessage + | FleetStatusMessage + | HookPreToolMessage + | HookPostToolMessage + | HookUserPromptMessage + | HookSessionStartMessage + | HookSessionEndMessage + | HookErrorMessage + | PongMessage + | SessionsChangedMessage; + +// ─── Session filesystem watcher ────────────────────────────────────────────── + +export interface SessionsChangedMessage { + type: 'sessions_changed'; +} + +// ─── File attachment (upload metadata) ─────────────────────────────────────── + +export interface FileAttachment { + path: string; + name: string; + size: number; + type: string; +} + +// ─── SDK attachment types (file, directory, selection) ─────────────────────── + +export type Attachment = + | { type: 'file'; path: string; name: string; displayName?: string } + | { type: 'directory'; path: string; name: string; displayName?: string } + | { + type: 'selection'; + filePath: string; + name: string; + displayName: string; + selection?: { + start: { line: number; character: number }; + end: { line: number; character: number }; + }; + text?: string; + }; + +// ─── Outgoing client messages (discriminated union on `type`) ──────────────── + +export interface InfiniteSessionsConfig { + enabled: boolean; + backgroundThreshold: number; + bufferThreshold: number; +} + +export interface NewSessionMessage { + type: 'new_session'; + model: string; + mode?: SessionMode; + reasoningEffort?: ReasoningEffort; + customInstructions?: string; + excludedTools?: string[]; + customTools?: CustomToolDefinition[]; + mcpServers?: McpServerDefinition[]; + disabledSkills?: string[]; + customAgents?: CustomAgentDefinition[]; + infiniteSessions?: InfiniteSessionsConfig; +} + +export type MessageDeliveryMode = 'immediate' | 'enqueue'; + +export interface SendMessage { + type: 'message'; + content: string; + attachments?: Attachment[]; + mode?: MessageDeliveryMode; +} + +export interface ListModelsMessage { + type: 'list_models'; +} + +export interface SetModeMessage { + type: 'set_mode'; + mode: SessionMode; +} + +export interface AbortClientMessage { + type: 'abort'; +} + +export interface SetModelMessage { + type: 'set_model'; + model: string; +} + +export interface SetReasoningMessage { + type: 'set_reasoning'; + effort: ReasoningEffort; +} + +export interface UserInputResponseMessage { + type: 'user_input_response'; + answer: string; + wasFreeform: boolean; +} + +export interface PermissionResponseMessage { + type: 'permission_response'; + requestId: string; + kind: string; + toolName: string; + decision: 'allow' | 'deny' | 'always_allow' | 'always_deny'; +} + +export interface ListToolsMessage { + type: 'list_tools'; + model?: string; +} + +export interface ListAgentsMessage { + type: 'list_agents'; +} + +export interface SelectAgentMessage { + type: 'select_agent'; + name: string; +} + +export interface DeselectAgentMessage { + type: 'deselect_agent'; +} + +export interface GetQuotaMessage { + type: 'get_quota'; +} + +export interface CompactMessage { + type: 'compact'; +} + +export interface ListSessionsMessage { + type: 'list_sessions'; +} + +export interface ResumeSessionMessage { + type: 'resume_session'; + sessionId: string; + mcpServers?: McpServerDefinition[]; +} + +export interface DeleteSessionMessage { + type: 'delete_session'; + sessionId: string; +} + +export interface GetSessionDetailMessage { + type: 'get_session_detail'; + sessionId: string; +} + +export interface GetPlanMessage { + type: 'get_plan'; +} + +export interface UpdatePlanMessage { + type: 'update_plan'; + content: string; +} + +export interface StartFleetMessage { + type: 'start_fleet'; + prompt: string; +} + +export interface DeletePlanMessage { + type: 'delete_plan'; +} + +export interface ClearChatMessage { + type: 'clear_chat'; +} + +export type ClientMessage = + | NewSessionMessage + | SendMessage + | ListModelsMessage + | SetModeMessage + | AbortClientMessage + | SetModelMessage + | SetReasoningMessage + | UserInputResponseMessage + | PermissionResponseMessage + | ListToolsMessage + | ListAgentsMessage + | SelectAgentMessage + | DeselectAgentMessage + | GetQuotaMessage + | CompactMessage + | ListSessionsMessage + | ResumeSessionMessage + | DeleteSessionMessage + | GetSessionDetailMessage + | GetPlanMessage + | UpdatePlanMessage + | DeletePlanMessage + | StartFleetMessage + | ClearChatMessage; + +// ─── Chat message type for rendering ──────────────────────────────────────── + +export type ChatMessageRole = + | 'user' + | 'assistant' + | 'tool' + | 'info' + | 'warning' + | 'error' + | 'intent' + | 'usage' + | 'skill' + | 'subagent' + | 'fleet' + | 'reasoning' + | 'queued'; + +export interface ChatMessage { + id: string; + role: ChatMessageRole; + content: string; + timestamp: number; + toolCallId?: string; + toolName?: string; + toolStatus?: ToolCallStatus; + toolProgressMessage?: string; + toolProgressMessages?: string[]; + mcpServerName?: string; + mcpToolName?: string; + agentName?: string; + skillName?: string; + fleetAgents?: Array<{ agentId: string; agentType: string; status: 'running' | 'completed' | 'failed'; error?: string }>; + inputTokens?: number; + outputTokens?: number; + reasoningTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + duration?: number; + cost?: number; + quotaSnapshots?: QuotaSnapshots; + copilotUsage?: CopilotUsageItem[]; + attachments?: Attachment[]; +} + +// ─── Tool call tracking ───────────────────────────────────────────────────── + +export type ToolCallStatus = 'running' | 'progress' | 'complete' | 'failed'; + +export interface ToolCallState { + id: string; + name: string; + mcpServerName?: string; + mcpToolName?: string; + status: ToolCallStatus; + message?: string; + progressMessages?: string[]; +} + +// ─── User input request state ─────────────────────────────────────────────── + +export interface UserInputState { + pending: boolean; + question: string; + choices?: string[]; + allowFreeform: boolean; +} + +// ─── Permission request state ─────────────────────────────────────────────── + +export interface PermissionRequestState { + requestId: string; + kind: string; + toolName: string; + toolArgs: Record; +} + +// ─── Context info state ───────────────────────────────────────────────────── + +export interface ContextInfo { + tokenLimit: number; + currentTokens: number; + messagesLength: number; +} + +// ─── Plan state ───────────────────────────────────────────────────────────── + +export interface PlanState { + exists: boolean; + content: string; + path?: string; +} + +// ─── New session configuration ────────────────────────────────────────────── + +export interface NewSessionConfig { + model: string; + mode?: SessionMode; + reasoningEffort?: ReasoningEffort; + customInstructions?: string; + excludedTools?: string[]; + customTools?: CustomToolDefinition[]; + mcpServers?: McpServerDefinition[]; + disabledSkills?: string[]; + customAgents?: CustomAgentDefinition[]; + infiniteSessions?: InfiniteSessionsConfig; +} + +// ─── Settings (persisted to localStorage) ─────────────────────────────────── + +export interface PersistedSettings { + model: string; + mode: SessionMode; + reasoningEffort: ReasoningEffort; + customInstructions: string; + excludedTools: string[]; + customTools: CustomToolDefinition[]; + mcpServers?: McpServerDefinition[]; + disabledSkills?: string[]; + customAgents?: CustomAgentDefinition[]; + infiniteSessions?: InfiniteSessionsConfig; + /** User preference for push notifications — persisted so it survives redeploys. */ + notificationsEnabled?: boolean; +} + +// ─── Custom agent definitions ─────────────────────────────────────────────── + +export interface CustomAgentDefinition { + name: string; + displayName?: string; + description?: string; + tools?: string[]; + prompt: string; +} + +// ─── Skill definitions ────────────────────────────────────────────────────── + +export interface SkillDefinition { + name: string; + description: string; + directory: string; + license?: string; + allowedTools?: string; +} diff --git a/src/lib/types/models.ts b/src/lib/types/models.ts deleted file mode 100644 index e3e8412..0000000 --- a/src/lib/types/models.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { ReasoningEffort } from './common.js'; - -export interface ModelCapabilities { - limits?: { - max_context_window_tokens?: number; - max_prompt_tokens?: number; - }; - supports?: { - vision?: boolean; - reasoningEffort?: boolean; - }; -} - -export interface ModelInfo { - id: string; - name: string; - billing?: { multiplier: number }; - capabilities?: ModelCapabilities; - defaultReasoningEffort?: ReasoningEffort; - supportedReasoningEfforts?: string[]; -} diff --git a/src/lib/types/quota.ts b/src/lib/types/quota.ts deleted file mode 100644 index ae41d03..0000000 --- a/src/lib/types/quota.ts +++ /dev/null @@ -1,43 +0,0 @@ -export interface QuotaSnapshot { - remainingPercentage?: number; - percentageUsed?: number; - resetDate?: string; - usedRequests?: number; - entitlementRequests?: number; - overage?: number; - isUnlimitedEntitlement?: boolean; -} - -export type QuotaSnapshots = Record; - -/** Priority order for picking the most relevant quota snapshot (UBB AI Credits first, legacy premium keys for older accounts) */ -const QUOTA_PRIORITY = ['ai_credits', 'aic', 'copilot_premium', 'premium_requests', 'premium_interactions'] as const; - -/** Pick the most relevant quota snapshot: AI Credit / premium types first, then any other key */ -export function pickPrimaryQuota(snapshots: QuotaSnapshots | null): { key: string; label: string; snapshot: QuotaSnapshot } | null { - if (!snapshots) return null; - const keys = Object.keys(snapshots); - if (keys.length === 0) return null; - - for (const k of QUOTA_PRIORITY) { - if (snapshots[k]) return { key: k, label: formatQuotaLabel(k), snapshot: snapshots[k] }; - } - const k = keys[0]; - return { key: k, label: formatQuotaLabel(k), snapshot: snapshots[k] }; -} - -/** Friendly display names under usage-based billing (UBB) */ -const QUOTA_LABELS: Record = { - ai_credits: 'AI Credits (AIC)', - aic: 'AI Credits (AIC)', - // Legacy premium-request keys are surfaced as AI Credits in the UI - copilot_premium: 'AI Credits (AIC)', - premium_requests: 'AI Credits (AIC)', - premium_interactions: 'AI Credits (AIC)', - chat: 'Chat', - completions: 'Completions', -}; - -function formatQuotaLabel(key: string): string { - return QUOTA_LABELS[key] ?? key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); -} diff --git a/src/lib/types/server-messages.ts b/src/lib/types/server-messages.ts deleted file mode 100644 index 19a038c..0000000 --- a/src/lib/types/server-messages.ts +++ /dev/null @@ -1,715 +0,0 @@ -import type { SessionMode, ReasoningEffort } from './common.js'; -import type { ModelInfo } from './models.js'; -import type { ToolInfo } from './tools.js'; -import type { QuotaSnapshots } from './quota.js'; -import type { SessionSummary, SessionDetail } from './sessions.js'; -import type { ChatMessage, CopilotUsageItem } from './chat.js'; -import type { CustomizationSource, SourcedAgentInfo, InstructionInfo, PromptInfo } from './customizations.js'; - -export interface ConnectedMessage { - type: 'connected'; - user: string; - sdkSessionId?: string | null; - hasPersistedState?: boolean; -} - -export interface ColdResumeMessage { - type: 'cold_resume'; - messages: Array>; - model: string; - mode: string; - sdkSessionId: string | null; -} - -export interface SessionCreatedMessage { - type: 'session_created'; - model: string; - sessionId?: string; -} - -export interface SessionReconnectedMessage { - type: 'session_reconnected'; - user: string; - hasSession: boolean; - isProcessing?: boolean; -} - -export interface TurnStartMessage { - type: 'turn_start'; -} - -export interface DeltaMessage { - type: 'delta'; - content: string; -} - -export interface TurnEndMessage { - type: 'turn_end'; - /** True when this message was replayed from the server buffer after reconnecting. */ - replayed?: boolean; -} - -export interface DoneMessage { - type: 'done'; - /** True when this message was replayed from the server buffer after reconnecting. */ - replayed?: boolean; -} - -export interface ReasoningDeltaMessage { - type: 'reasoning_delta'; - content: string; - reasoningId: string; -} - -export interface ReasoningDoneMessage { - type: 'reasoning_done'; - reasoningId: string; - content?: string; -} - -export interface IntentMessage { - type: 'intent'; - intent: string; -} - -export interface ToolStartMessage { - type: 'tool_start'; - toolCallId: string; - toolName: string; - mcpServerName?: string; - mcpToolName?: string; -} - -export interface ToolProgressMessage { - type: 'tool_progress'; - toolCallId: string; - message: string; -} - -export interface ToolEndMessage { - type: 'tool_end'; - toolCallId: string; - /** False when the tool execution failed */ - success?: boolean; - /** Human-readable error message when success is false */ - error?: string; -} - -export interface ModelsMessage { - type: 'models'; - models: (ModelInfo | string)[]; -} - -export interface ModeChangedMessage { - type: 'mode_changed'; - mode: SessionMode; -} - -export interface ModelChangedMessage { - type: 'model_changed'; - model: string; - source?: 'sdk' | string; -} - -export interface TitleChangedMessage { - type: 'title_changed'; - title: string; -} - -export interface UsageMessage { - type: 'usage'; - inputTokens?: number; - outputTokens?: number; - totalTokens?: number; - reasoningTokens?: number; - cacheReadTokens?: number; - cacheWriteTokens?: number; - duration?: number; - cost?: number; - quotaSnapshots?: QuotaSnapshots; - copilotUsage?: CopilotUsageItem[]; -} - -export interface WarningMessage { - type: 'warning'; - message: string; -} - -export interface ErrorMessage { - type: 'error'; - message: string; -} - -export interface AbortedMessage { - type: 'aborted'; -} - -export interface UserInputRequestMessage { - type: 'user_input_request'; - question: string; - choices?: string[]; - allowFreeform: boolean; -} - -export interface PermissionRequestMessage { - type: 'permission_request'; - requestId: string; - kind: string; - toolName: string; - toolArgs: Record; -} - -export interface ToolsMessage { - type: 'tools'; - tools: ToolInfo[]; -} - -export interface AgentsMessage { - type: 'agents'; - agents: SourcedAgentInfo[]; - current: string | null; -} - -export interface AgentChangedMessage { - type: 'agent_changed'; - agent: string | null; -} - -export interface QuotaMessage { - type: 'quota'; - quotaSnapshots?: QuotaSnapshots; -} - -export interface SessionsMessage { - type: 'sessions'; - sessions: SessionSummary[]; -} - -export interface SessionDetailMessage { - type: 'session_detail'; - detail: SessionDetail; -} - -export interface SessionHistoryMessage { - type: 'session_history'; - messages: ChatMessage[]; -} - -export interface SessionResumedMessage { - type: 'session_resumed'; - sessionId: string; -} - -export interface SessionDeletedMessage { - type: 'session_deleted'; - sessionId: string; -} - -export interface PlanMessage { - type: 'plan'; - exists: boolean; - content?: string; - path?: string; -} - -export interface PlanChangedMessage { - type: 'plan_changed'; - content?: string; - path?: string; -} - -export interface PlanUpdatedMessage { - type: 'plan_updated'; - content?: string; - path?: string; -} - -export interface PlanDeletedMessage { - type: 'plan_deleted'; -} - -export interface CompactionStartMessage { - type: 'compaction_start'; -} - -export interface CompactionCompleteMessage { - type: 'compaction_complete'; - tokensRemoved?: number; - messagesRemoved?: number; - preCompactionTokens?: number; - postCompactionTokens?: number; -} - -export interface CompactionResultMessage { - type: 'compaction_result'; - tokensRemoved?: number; - messagesRemoved?: number; -} - -export interface SkillInvokedMessage { - type: 'skill_invoked'; - skillName: string; -} - -export interface SubagentStartMessage { - type: 'subagent_start'; - agentName: string; - description?: string; -} - -export interface SubagentEndMessage { - type: 'subagent_end'; - agentName: string; -} - -export interface SubagentFailedMessage { - type: 'subagent_failed'; - agentName?: string; - error?: string; -} - -export interface SubagentSelectedMessage { - type: 'subagent_selected'; - agentName: string; -} - -export interface SubagentDeselectedMessage { - type: 'subagent_deselected'; - agentName?: string; -} - -export interface InfoMessage { - type: 'info'; - message: string; - infoType?: string; - url?: string; -} - -/** Published when a remote-enabled session receives its github.com monitoring URL */ -export interface RemoteSessionUrlMessage { - type: 'remote_session_url'; - url: string; - message?: string; -} - -/** Result of toggling remote steering on the active session */ -export interface RemoteToggledMessage { - type: 'remote_toggled'; - enabled: boolean; -} - -/** Confirmation that a cloud session was created on GitHub's infrastructure */ -export interface CloudSessionCreatedMessage { - type: 'cloud_session_created'; - sessionId?: string; - model?: string; - repository?: { owner: string; name: string; branch?: string }; -} - -export interface ElicitationRequestedMessage { - type: 'elicitation_requested'; - elicitationId?: string; - message?: string; - requestedSchema?: Record; - mode?: string; - elicitationSource?: string; - /** Legacy fields for backward compat */ - question?: string; - choices?: string[]; - allowFreeform?: boolean; -} - -export interface ElicitationCompletedMessage { - type: 'elicitation_completed'; - answer?: string; -} - -export interface ExitPlanModeRequestedMessage { - type: 'exit_plan_mode_requested'; -} - -export interface ExitPlanModeCompletedMessage { - type: 'exit_plan_mode_completed'; -} - -export interface ContextInfoMessage { - type: 'context_info'; - tokenLimit: number; - currentTokens: number; - messagesLength: number; -} - -export interface ReasoningChangedMessage { - type: 'reasoning_changed'; - effort: ReasoningEffort; -} - -export interface SessionShutdownMessage { - type: 'session_shutdown'; - totalPremiumRequests?: number; - totalApiDurationMs?: number; - sessionStartTime?: string; -} - -export interface SessionIdleMessage { - type: 'session_idle'; - backgroundTasks?: { - agents: Array<{ agentId: string; agentType: string }>; - }; -} - -export interface TaskCompleteMessage { - type: 'task_complete'; - summary?: string; -} - -export interface TruncationMessage { - type: 'truncation'; - tokenLimit: number; - preTruncationTokens: number; - preTruncationMessages: number; - postTruncationTokens: number; - postTruncationMessages: number; -} - -export interface ToolPartialResultMessage { - type: 'tool_partial_result'; - toolCallId: string; - partialOutput: string; -} - -export interface ContextChangedMessage { - type: 'context_changed'; - cwd: string; - gitRoot?: string; - repository?: string; - branch?: string; -} - -export interface WorkspaceFileChangedMessage { - type: 'workspace_file_changed'; - path: string; - operation: 'create' | 'update'; -} - -export interface FleetStartedMessage { - type: 'fleet_started'; - started: boolean; -} - -export interface FleetStatusMessage { - type: 'fleet_status'; - agents: Array<{ agentId: string; agentType: string }>; -} - -export interface SystemNotificationMessage { - type: 'system_notification'; - content?: string; - kind?: Record; -} - -export interface HookPreToolMessage { - type: 'hook_pre_tool'; - toolName: string; - toolArgs?: unknown; -} - -export interface HookPostToolMessage { - type: 'hook_post_tool'; - toolName: string; - toolArgs?: unknown; -} - -export interface HookToolFailureMessage { - type: 'hook_tool_failure'; - toolName: string; - toolArgs?: unknown; - error: string; -} - -export interface HookSessionStartMessage { - type: 'hook_session_start'; - source: string; -} - -export interface HookSessionEndMessage { - type: 'hook_session_end'; - reason: string; -} - -export interface HookUserPromptMessage { - type: 'hook_user_prompt'; - prompt: string; -} - -export interface HookErrorMessage { - type: 'hook_error'; - error: string; - errorContext: string; - recoverable: boolean; -} - -export type HookMessage = - | HookPreToolMessage - | HookPostToolMessage - | HookToolFailureMessage - | HookUserPromptMessage - | HookSessionStartMessage - | HookSessionEndMessage - | HookErrorMessage; - -export interface SessionUsageTotals { - inputTokens: number; - outputTokens: number; - reasoningTokens: number; - cacheReadTokens: number; - cacheWriteTokens: number; - totalCost: number; - totalDurationMs: number; - apiCalls: number; - premiumRequests: number; -} - -/** Server heartbeat response to client-side ping */ -export interface PongMessage { - type: 'pong'; -} - -export interface SkillsListMessage { - type: 'skills_list'; - skills: { - name: string; - description: string; - source: CustomizationSource; - userInvocable: boolean; - enabled: boolean; - path?: string; - }[]; -} - -export interface SkillToggledMessage { - type: 'skill_toggled'; - name: string; - enabled: boolean; -} - -export interface SkillsReloadedMessage { - type: 'skills_reloaded'; -} - -export interface McpServersListMessage { - type: 'mcp_servers_list'; - servers: { - name: string; - status: 'connected' | 'failed' | 'pending' | 'disabled' | 'not_configured'; - source?: string; - error?: string; - }[]; -} - -export interface McpServerToggledMessage { - type: 'mcp_server_toggled'; - name: string; - enabled: boolean; -} - -export interface SessionsChangedMessage { - type: 'sessions_changed'; -} - -export interface InstructionsListMessage { - type: 'instructions_list'; - instructions: InstructionInfo[]; -} - -export interface PromptsListMessage { - type: 'prompts_list'; - prompts: PromptInfo[]; -} - -export interface PromptContentMessage { - type: 'prompt_content'; - name: string; - content: string; -} - -export interface ExtensionsListMessage { - type: 'extensions_list'; - extensions: { - name: string; - description?: string; - enabled: boolean; - }[]; -} - -export interface ExtensionToggledMessage { - type: 'extension_toggled'; - name: string; - enabled: boolean; -} - -export interface ExtensionsReloadedMessage { - type: 'extensions_reloaded'; -} - -export interface ShellExecResultMessage { - type: 'shell_exec_result'; - stdout: string; - stderr: string; - exitCode: number | null; - pid?: number; -} - -export interface ShellKillResultMessage { - type: 'shell_kill_result'; - success: boolean; -} - -export interface WorkspaceFilesListMessage { - type: 'workspace_files_list'; - files: string[]; -} - -export interface WorkspaceFileContentMessage { - type: 'workspace_file_content'; - path: string; - content: string; -} - -export interface WorkspaceFileCreatedMessage { - type: 'workspace_file_created'; - path: string; -} - -// Phase 10: newly wired SDK event messages -export interface SessionStartedMessage { type: 'session_start'; } -export interface SessionResumedSdkMessage { type: 'session_resume'; } -export interface RemoteSteerableChangedMessage { type: 'remote_steerable_changed'; steerable?: boolean; } -export interface SnapshotRewindMessage { type: 'snapshot_rewind'; } -export interface SessionHandoffMessage { type: 'session_handoff'; } -export interface BackgroundTasksChangedMessage { type: 'background_tasks_changed'; agents?: Array<{ agentId: string; agentType: string }>; } -export interface SkillsLoadedMessage { type: 'skills_loaded'; } -export interface CustomAgentsUpdatedMessage { type: 'custom_agents_updated'; } -export interface McpServerStatusChangedMessage { type: 'mcp_server_status_changed'; name: string; status: string; error?: string; } -export interface ExtensionsLoadedMessage { type: 'extensions_loaded'; } -export interface AbortSdkMessage { type: 'abort'; } -export interface McpOauthRequiredMessage { type: 'mcp_oauth_required'; serverName: string; authUrl?: string; } -export interface McpOauthCompletedMessage { type: 'mcp_oauth_completed'; serverName: string; } -export interface SamplingRequestedMessage { type: 'sampling_requested'; } -export interface SamplingCompletedMessage { type: 'sampling_completed'; } -export interface ExternalToolRequestedMessage { type: 'external_tool_requested'; toolName?: string; } -export interface ExternalToolCompletedMessage { type: 'external_tool_completed'; toolName?: string; } -export interface ToolsUpdatedMessage { type: 'tools_updated'; } -export interface McpServersLoadedMessage { type: 'mcp_servers_loaded'; } - -export type ServerMessage = - | ConnectedMessage - | ColdResumeMessage - | SessionCreatedMessage - | SessionReconnectedMessage - | TurnStartMessage - | DeltaMessage - | TurnEndMessage - | DoneMessage - | ReasoningDeltaMessage - | ReasoningDoneMessage - | IntentMessage - | ToolStartMessage - | ToolProgressMessage - | ToolEndMessage - | ModelsMessage - | ModeChangedMessage - | ModelChangedMessage - | TitleChangedMessage - | UsageMessage - | WarningMessage - | ErrorMessage - | AbortedMessage - | UserInputRequestMessage - | PermissionRequestMessage - | ToolsMessage - | AgentsMessage - | AgentChangedMessage - | QuotaMessage - | SessionsMessage - | SessionDetailMessage - | SessionHistoryMessage - | SessionResumedMessage - | SessionDeletedMessage - | PlanMessage - | PlanChangedMessage - | PlanUpdatedMessage - | PlanDeletedMessage - | CompactionStartMessage - | CompactionCompleteMessage - | CompactionResultMessage - | SkillInvokedMessage - | SubagentStartMessage - | SubagentEndMessage - | SubagentFailedMessage - | SubagentSelectedMessage - | SubagentDeselectedMessage - | InfoMessage - | RemoteSessionUrlMessage - | RemoteToggledMessage - | CloudSessionCreatedMessage - | ElicitationRequestedMessage - | ElicitationCompletedMessage - | ExitPlanModeRequestedMessage - | ExitPlanModeCompletedMessage - | ContextInfoMessage - | ReasoningChangedMessage - | SessionShutdownMessage - | SessionIdleMessage - | TaskCompleteMessage - | TruncationMessage - | ToolPartialResultMessage - | ContextChangedMessage - | WorkspaceFileChangedMessage - | FleetStartedMessage - | FleetStatusMessage - | SystemNotificationMessage - | HookPreToolMessage - | HookPostToolMessage - | HookToolFailureMessage - | HookUserPromptMessage - | HookSessionStartMessage - | HookSessionEndMessage - | HookErrorMessage - | PongMessage - | SkillsListMessage - | SkillToggledMessage - | SkillsReloadedMessage - | McpServersListMessage - | McpServerToggledMessage - | SessionsChangedMessage - | InstructionsListMessage - | PromptsListMessage - | PromptContentMessage - | SessionStartedMessage - | SessionResumedSdkMessage - | RemoteSteerableChangedMessage - | SnapshotRewindMessage - | SessionHandoffMessage - | BackgroundTasksChangedMessage - | SkillsLoadedMessage - | CustomAgentsUpdatedMessage - | McpServerStatusChangedMessage - | ExtensionsLoadedMessage - | AbortSdkMessage - | McpOauthRequiredMessage - | McpOauthCompletedMessage - | SamplingRequestedMessage - | SamplingCompletedMessage - | ExternalToolRequestedMessage - | ExternalToolCompletedMessage - | ToolsUpdatedMessage - | McpServersLoadedMessage - | ShellExecResultMessage - | ShellKillResultMessage - | WorkspaceFilesListMessage - | WorkspaceFileContentMessage - | WorkspaceFileCreatedMessage - | ExtensionsListMessage - | ExtensionToggledMessage - | ExtensionsReloadedMessage; diff --git a/src/lib/types/sessions.ts b/src/lib/types/sessions.ts deleted file mode 100644 index 4db1187..0000000 --- a/src/lib/types/sessions.ts +++ /dev/null @@ -1,33 +0,0 @@ -export interface SessionSummary { - id: string; - title?: string; - model?: string; - updatedAt?: string; - cwd?: string; - repository?: string; - branch?: string; - checkpointCount?: number; - hasPlan?: boolean; - isRemote?: boolean; - /** Where the session was found: 'sdk' = indexed by Copilot CLI, 'filesystem' = on-disk only (bundled) */ - source?: 'sdk' | 'filesystem'; -} - -export interface CheckpointEntry { - number: number; - title: string; - filename: string; -} - -export interface SessionDetail { - id: string; - cwd?: string; - repository?: string; - branch?: string; - summary?: string; - createdAt?: string; - updatedAt?: string; - checkpoints: CheckpointEntry[]; - plan?: string; - isRemote?: boolean; -} diff --git a/src/lib/types/state.ts b/src/lib/types/state.ts deleted file mode 100644 index fae0a2a..0000000 --- a/src/lib/types/state.ts +++ /dev/null @@ -1,46 +0,0 @@ -export interface UserInputState { - pending: boolean; - question: string; - choices?: string[]; - allowFreeform: boolean; -} - -export interface PermissionRequestState { - requestId: string; - kind: string; - toolName: string; - toolArgs: Record; -} - -export interface ContextInfo { - tokenLimit: number; - currentTokens: number; - messagesLength: number; -} - -export interface ElicitationState { - elicitationId?: string; - message?: string; - requestedSchema?: { - type?: string; - properties?: Record; - required?: string[]; - }; - mode?: string; - elicitationSource?: string; -} - -export interface PlanState { - exists: boolean; - content: string; - path?: string; -} diff --git a/src/lib/types/tools.ts b/src/lib/types/tools.ts deleted file mode 100644 index 3d4a1fd..0000000 --- a/src/lib/types/tools.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface ToolInfo { - name: string; - namespacedName?: string; - description?: string; - mcpServerName?: string; -} - -export interface AgentInfo { - name: string; - description?: string; -} diff --git a/src/lib/utils/customization-helpers.ts b/src/lib/utils/customization-helpers.ts deleted file mode 100644 index a322c31..0000000 --- a/src/lib/utils/customization-helpers.ts +++ /dev/null @@ -1,63 +0,0 @@ -import type { CustomizationSource, SourcedMcpServerInfo } from '$lib/types/index.js'; - -export function groupBySource(items: T[]): Map { - const groups = new Map(); - const order: CustomizationSource[] = ['builtin', 'repo', 'user']; - for (const src of order) { - const filtered = items.filter(i => i.source === src); - if (filtered.length > 0) groups.set(src, filtered); - } - return groups; -} - -/** Normalize SDK source strings to standard labels */ -export function normalizeCustomizationSource(raw: string | undefined): CustomizationSource { - const src = (raw ?? '').toLowerCase(); - return (src === 'personal' || src === 'user') - ? 'user' - : (src === 'project' || src === 'workspace' || src === 'repo') - ? 'repo' - : 'builtin'; -} - -/** Merge incoming MCP server list with existing, deduplicating by name. - * Only overwrites fields when the incoming value is defined, and prefers - * live status (connected/failed) over static status (not_configured). */ -export function mergeMcpServers( - current: SourcedMcpServerInfo[], - incoming: Array<{ - name: string; - source?: string; - status: string; - type?: string; - url?: string; - command?: string; - error?: string; - }>, -): SourcedMcpServerInfo[] { - const merged = new Map(current.map((server) => [server.name.toLowerCase(), server])); - - for (const server of incoming) { - const key = server.name.toLowerCase(); - const existing = merged.get(key); - - // Pick the more informative status: live status wins over static - const existingIsLive = existing?.status === 'connected' || existing?.status === 'failed' || existing?.status === 'auth_required'; - const incomingIsLive = server.status === 'connected' || server.status === 'failed'; - const status = incomingIsLive ? server.status - : existingIsLive ? existing!.status - : server.status || existing?.status || 'not_configured'; - - merged.set(key, { - name: server.name, - source: normalizeCustomizationSource(server.source ?? existing?.source), - status, - type: server.type ?? existing?.type, - url: server.url ?? existing?.url, - command: server.command ?? existing?.command, - error: incomingIsLive ? server.error : (server.error ?? existing?.error), - }); - } - - return [...merged.values()]; -} diff --git a/src/lib/utils/sw-register.test.ts b/src/lib/utils/sw-register.test.ts index aba9cdc..9747d34 100644 --- a/src/lib/utils/sw-register.test.ts +++ b/src/lib/utils/sw-register.test.ts @@ -38,18 +38,17 @@ describe('registerServiceWorker', () => { }); it('returns registration and logs scope when registration succeeds', async () => { - const mockRegistration = { scope: '/', update: vi.fn().mockResolvedValue(undefined) } as unknown as ServiceWorkerRegistration; + const mockRegistration = { scope: '/' } as ServiceWorkerRegistration; const registerMock = vi.fn().mockResolvedValue(mockRegistration); Object.defineProperty(navigator, 'serviceWorker', { configurable: true, - value: { register: registerMock, addEventListener: vi.fn() }, + value: { register: registerMock }, }); const result = await registerServiceWorker(); expect(result).toBe(mockRegistration); - expect(registerMock).toHaveBeenCalledWith('/sw.js?v=2026-03-25-2', { scope: '/' }); - expect(mockRegistration.update).toHaveBeenCalled(); + expect(registerMock).toHaveBeenCalledWith('/sw.js', { scope: '/' }); expect(console.log).toHaveBeenCalledWith('[SW] Registered with scope:', '/'); }); @@ -57,7 +56,7 @@ describe('registerServiceWorker', () => { const registerMock = vi.fn().mockRejectedValue(new Error('Registration failed')); Object.defineProperty(navigator, 'serviceWorker', { configurable: true, - value: { register: registerMock, addEventListener: vi.fn() }, + value: { register: registerMock }, }); const result = await registerServiceWorker(); diff --git a/src/lib/utils/sw-register.ts b/src/lib/utils/sw-register.ts index c053867..9a7f9bf 100644 --- a/src/lib/utils/sw-register.ts +++ b/src/lib/utils/sw-register.ts @@ -1,5 +1,3 @@ -const SW_VERSION = '2026-03-25-2'; - export async function registerServiceWorker(): Promise { if (!('serviceWorker' in navigator)) { console.warn('[SW] Service workers not supported'); @@ -7,17 +5,9 @@ export async function registerServiceWorker(): Promise { - if (reloadedForControllerChange) return; - reloadedForControllerChange = true; - window.location.reload(); - }); - - const registration = await navigator.serviceWorker.register(`/sw.js?v=${SW_VERSION}`, { + const registration = await navigator.serviceWorker.register('/sw.js', { scope: '/', }); - await registration.update(); console.log('[SW] Registered with scope:', registration.scope); return registration; } catch (err) { diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts index d2cc203..176858e 100644 --- a/src/routes/+layout.server.ts +++ b/src/routes/+layout.server.ts @@ -1,15 +1,12 @@ import type { LayoutServerLoad } from './$types'; import { checkAuth } from '$lib/server/auth/guard'; -import { config } from '$lib/server/config'; -import { debug } from '$lib/server/logger'; export const load: LayoutServerLoad = async ({ locals }) => { - debug(`[LAYOUT-LOAD] locals.session exists=${!!locals.session} hasToken=${!!locals.session?.githubToken} user=${locals.session?.githubUser?.login ?? 'none'}`); + console.log(`[LAYOUT-LOAD] locals.session exists=${!!locals.session} hasToken=${!!locals.session?.githubToken} user=${locals.session?.githubUser?.login ?? 'none'}`); const auth = checkAuth(locals.session); - debug(`[LAYOUT-LOAD] checkAuth result: authenticated=${auth.authenticated} user=${auth.user?.login ?? 'none'} error=${auth.error ?? 'none'}`); + console.log(`[LAYOUT-LOAD] checkAuth result: authenticated=${auth.authenticated} user=${auth.user?.login ?? 'none'} error=${auth.error ?? 'none'}`); return { authenticated: auth.authenticated, user: auth.user, - byokEnabled: config.byokEnabled, }; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index db24c77..8c7721d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,24 +1,20 @@ @@ -358,42 +307,17 @@ {#if data.authenticated} -
- + sidebarOpen = false} - onNewChat={handleNewChat} - onOpenSessions={handleOpenSessions} - onOpenSettings={handleOpenSettings} - onLogout={handleLogout} - onToggleCollapse={() => sidebarCollapsed = !sidebarCollapsed} - onResumeSession={handleResumeSession} + {activeSkillCount} + onToggleSidebar={() => sidebarOpen = true} + onOpenModelSheet={() => modelSheetOpen = true} /> -
- sidebarOpen = true} - onOpenModelSheet={() => modelSheetOpen = true} - /> - - {#if showRemoteBanner && chatStore.remoteUrl} - { dismissedRemoteUrl = chatStore.remoteUrl; }} - /> - {/if} -
{#if sessionLoading}
@@ -413,7 +337,7 @@ /> {/if} - + {#if chatStore.messages.length === 0} {/if} @@ -430,17 +354,6 @@ {/if} - {#if chatStore.pendingElicitation} - - {/if} - {#if chatStore.pendingPermissions.length > 0} {#each chatStore.pendingPermissions as perm (perm.requestId)} wsStore.abort()} onSetMode={handleSetMode} @@ -474,17 +385,20 @@ onNewChat={handleNewChat} onOpenModelSheet={() => { modelSheetOpen = true; }} onCompact={() => wsStore.compact()} - onOpenSessions={handleOpenSessions} - onOpenSettings={() => { - sidebarOpen = false; - settingsOpen = true; - }} />
- -
- + sidebarOpen = false} + onNewChat={handleNewChat} + onOpenSessions={handleOpenSessions} + onOpenSettings={handleOpenSettings} + onLogout={handleLogout} + /> { settingsOpen = false; }} - onSaveInstructions={(v) => { settings.additionalInstructions = v; }} + disabledSkills={settings.disabledSkills} + onClose={() => settingsOpen = false} + onSaveInstructions={(v) => { settings.customInstructions = v; }} onToggleTool={(name, enabled) => { if (enabled) { settings.excludedTools = settings.excludedTools.filter(t => t !== name); @@ -518,56 +432,19 @@ settings.excludedTools = [...settings.excludedTools, name]; } }} + onSaveCustomTools={(tools) => { settings.customTools = tools; }} + onSaveCustomAgents={(agents) => { settings.customAgents = agents; }} + onSaveMcpServers={(servers) => { settings.mcpServers = servers; }} + onToggleSkill={handleToggleSkill} onSelectAgent={(name) => wsStore.selectAgent(name)} onDeselectAgent={() => wsStore.deselectAgent()} onCompact={() => wsStore.compact()} onFetchTools={() => wsStore.listTools(chatStore.currentModel)} onFetchAgents={() => wsStore.listAgents()} onFetchQuota={() => wsStore.getQuota()} - onFetchSkills={() => wsStore.send({ type: 'list_skills_rpc' })} - onFetchExtensions={() => wsStore.send({ type: 'list_extensions' })} - onFetchMcpServers={() => { - wsStore.send({ type: 'list_mcp_rpc' }); - fetch('/api/customizations') - .then(async (res) => { - if (!res.ok) return null; - return await res.json() as { - mcpServers?: Array<{ - name: string; - source?: string; - status: string; - type?: string; - url?: string; - command?: string; - error?: string; - }>; - }; - }) - .then((body) => { - if (!body?.mcpServers) return; - settings.discoveredMcpServers = mergeMcpServers(settings.discoveredMcpServers, body.mcpServers); - }) - .catch(() => { /* ignore fetch errors */ }); - }} - onFetchInstructions={() => wsStore.send({ type: 'list_instructions' })} - onFetchPrompts={() => wsStore.send({ type: 'list_prompts' })} - onToggleSkill={(name, enabled) => wsStore.send({ type: 'toggle_skill_rpc', name, enabled })} - onToggleExtension={(name, enabled) => wsStore.send({ type: 'toggle_extension', name, enabled })} - onReloadExtensions={() => wsStore.send({ type: 'reload_extensions' })} - onToggleMcpServer={(name, enabled) => wsStore.send({ type: 'toggle_mcp_rpc', name, enabled })} + onFetchSkills={() => settings.fetchSkills()} notificationsEnabled={settings.notificationsEnabled} onToggleNotifications={(v) => { settings.notificationsEnabled = v; }} - remoteSessionMode={settings.remoteSession} - onSetRemoteSessionMode={(mode) => { settings.remoteSession = mode; }} - remoteSessionActive={wsStore.sessionReady} - onApplyRemoteToSession={(mode) => wsStore.remoteToggle(mode)} - voiceInputEnabled={settings.voiceInputEnabled} - onToggleVoiceInput={(v) => { settings.voiceInputEnabled = v; }} - ttsEnabled={settings.ttsEnabled} - onToggleTts={(v) => { settings.ttsEnabled = v; }} - ttsRate={settings.ttsRate} - onSetTtsRate={(v) => { settings.ttsRate = v; ttsStore.rate = v; }} - byokEnabled={data.byokEnabled} /> wsStore.deleteSession(id)} onRequestDetail={(id) => wsStore.getSessionDetail(id)} - onNewCloudSession={(repository) => { - chatStore.clearMessages(); - wsStore.newCloudSession({ - ...(settings.selectedModel && { model: settings.selectedModel }), - mode: settings.selectedMode, - repository, - }); - }} />
{:else} @@ -594,27 +463,6 @@ {/if}