diff --git a/.gitattributes b/.gitattributes index 22bfc42c..9a18a592 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,8 @@ # scripts/build-native-helpers.mjs generates (LF). Checked out with LF everywhere, a Windows checkout # (core.autocrlf=true) matches and a helper built there embeds the same bytes as one built on macOS or Linux. native/canvastty-helper/catalog.json text eol=lf + +# Unified diffs require space-prefixed blank context lines, including at EOF. +# Preserve the patch bytes across Windows checkouts without treating that +# required patch syntax as trailing whitespace in application code. +patches/*.patch text eol=lf whitespace=-blank-at-eol,-blank-at-eof diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 247d06ae..258da997 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,25 @@ env: CANVASTTY_REQUIRE_NATIVE_HELPERS: "1" jobs: + mascot-pipeline: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@v7 + - name: Install mascot pipeline dependencies in a virtual environment + run: | + python3 -m venv .mascot-venv + .mascot-venv/bin/python -m pip install -r assets/mascot-pipeline/requirements.txt + - name: Verify mascot intake, build, review, and delivery contracts + run: .mascot-venv/bin/python -B assets/mascot-pipeline/tests/smoke_pipeline.py + - name: Verify placement against bundled accepted artwork + run: .mascot-venv/bin/python -B assets/mascot-pipeline/tests/placement_regression.py + verify: runs-on: ubuntu-latest steps: @@ -49,6 +68,17 @@ jobs: run: npm run typecheck - name: Build production bundles run: npm run build + - name: Build Linux installers and verify bundled mascot resources + run: | + npm run package:linux + test -f release/linux-unpacked/resources/mascot-pipeline/SKILL.md + test -f release/linux-unpacked/resources/mascot-pipeline/scripts/prepare_input.py + test -f release/linux-unpacked/resources/mascot-pipeline/templates/plugin/player.js + - name: Install AppImage runtime dependency + run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libfuse2t64 + - name: Smoke-test packaged Linux AppImage + timeout-minutes: 2 + run: xvfb-run -a npm run smoke:appimage - name: Verify hidden terminal rendering and state restoration timeout-minutes: 2 run: xvfb-run -a npm run smoke:terminal-hidden @@ -116,6 +146,13 @@ jobs: env: SPARKLE_PUBLIC_ED_KEY: ${{ vars.SPARKLE_PUBLIC_ED_KEY || 'iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=' }} run: npm run package:mac + - name: Verify bundled macOS mascot resources + run: | + app="$(find release -type d -name 'CanvasTTY.app' -print -quit)" + test -n "$app" + test -f "$app/Contents/Resources/mascot-pipeline/SKILL.md" + test -f "$app/Contents/Resources/mascot-pipeline/scripts/prepare_input.py" + test -f "$app/Contents/Resources/mascot-pipeline/templates/plugin/player.js" - name: Smoke-test packaged macOS CLI resolution run: npm run smoke:cli-resolution:mac - name: Verify hidden terminal rendering and state restoration on macOS diff --git a/.github/workflows/pty-resources.yml b/.github/workflows/pty-resources.yml new file mode 100644 index 00000000..81ead94e --- /dev/null +++ b/.github/workflows/pty-resources.yml @@ -0,0 +1,106 @@ +name: macOS PTY resources + +on: + push: + paths: + - ".github/workflows/pty-resources.yml" + - "patches/**" + - "package.json" + - "package-lock.json" + - "electron-builder.yml" + - "scripts/smoke-pty-resources.mjs" + - "scripts/audit-pty-test-bundle.mjs" + - "scripts/fixtures/public-speech-source-path-hashes.json" + - "tests/node-pty*" + pull_request: + paths: + - ".github/workflows/pty-resources.yml" + - "patches/**" + - "package.json" + - "package-lock.json" + - "electron-builder.yml" + - "scripts/smoke-pty-resources.mjs" + - "scripts/audit-pty-test-bundle.mjs" + - "scripts/fixtures/public-speech-source-path-hashes.json" + - "tests/node-pty*" + workflow_dispatch: + +permissions: + contents: read + +jobs: + native-and-packaged: + runs-on: macos-latest + timeout-minutes: 45 + env: + CANVASTTY_REQUIRE_NATIVE_HELPERS: "1" + steps: + - name: Check out repository + uses: actions/checkout@v7 + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: native/canvastty-helper/go.mod + cache: false + - name: Install locked dependencies without applying the patch yet + run: npm ci --ignore-scripts + - name: Build the unpatched control for Electron from source + run: | + npx --no-install install-electron + npx --no-install electron-rebuild -f -w node-pty --build-from-source + - name: Reproduce the original leak and stop at the first growth + run: node scripts/smoke-pty-resources.mjs --cycles 3 --expect-leak --report .pty-results/unpatched.json + - name: Apply the tracked patch and rebuild for Electron + run: npm run postinstall + - name: Check cleanup under injected native failures + run: node --test tests/node-pty-patch.test.mjs tests/node-pty-bundle-audit.test.mjs + - name: Verify 400 cycles in one persistent Electron parent + run: node scripts/smoke-pty-resources.mjs --cycles 400 --report .pty-results/source.json + - name: Verify every combination of closed standard descriptors + run: | + for mask in 1 2 3 4 5 6 7; do + node scripts/smoke-pty-resources.mjs --cycles 8 --stdio-mask "$mask" --report ".pty-results/source-stdio-$mask.json" + done + - name: Build a separate macOS application without publishing it + env: + # Hosted read-only PR runner; mac.identity is explicitly ad-hoc ("-"). + # No signing certificate/keychain secrets are provided to this job. + CSC_FOR_PULL_REQUEST: "true" + SPARKLE_PUBLIC_ED_KEY: "iojj3XQJ8ZX9UtstPLpdcspnCb8dlBIb83SIAbQPb1w=" + run: npm run package:mac + - name: Audit the separate test bundle + run: node scripts/audit-pty-test-bundle.mjs release/mac-arm64/CanvasTTY.app + - name: Verify the new bundle signature + run: codesign --verify --deep --strict release/mac-arm64/CanvasTTY.app + - name: Verify 400 cycles using the packaged native addon + run: node scripts/smoke-pty-resources.mjs --app release/mac-arm64/CanvasTTY.app --cycles 400 --report .pty-results/packaged.json + - name: Verify closed standard descriptors in the packaged addon + run: | + for mask in 1 2 3 4 5 6 7; do + node scripts/smoke-pty-resources.mjs --app release/mac-arm64/CanvasTTY.app --cycles 8 --stdio-mask "$mask" --report ".pty-results/packaged-stdio-$mask.json" + done + - name: Retain synthetic test evidence even on failure + if: always() + uses: actions/upload-artifact@v7 + with: + name: macos-pty-resources + if-no-files-found: warn + retention-days: 14 + include-hidden-files: true + path: | + .pty-results/*.json + - name: Retain the separate build only after all safety and runtime gates + if: success() + uses: actions/upload-artifact@v7 + with: + name: macos-pty-test-build + if-no-files-found: error + retention-days: 14 + path: | + release/*.zip + release/*.dmg diff --git a/.gitignore b/.gitignore index 3534bad6..dec313dc 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,11 @@ yarn-debug.log* yarn-error.log* .cache/ .electron/ +__pycache__/ +*.pyc +/.mascot-venv/ settings.json +.pty-results/ # Local agent and planning context — never publish .agents/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 933dbdc6..0ca621af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ [English](CHANGELOG.md) · [Русский](CHANGELOG.ru.md) · [简体中文](CHANGELOG.zh-CN.md) +## Unreleased + +- Added a rich file viewer to the **Files** card: Markdown files (`.md`, `.markdown`, `.mdown`, `.mkd`) render as formatted GitHub-Flavored Markdown with headings, lists, task lists, tables, blockquotes, horizontal rules, inline code, and fenced code blocks with syntax highlighting, recognized code files are syntax-highlighted by the language detected from their extension while unknown languages stay plain monospaced text, all rendered content is sanitized so raw HTML, scripts, event-handler attributes, and dangerous link schemes never render, `http(s)` links open in the app's built-in browser with other schemes inert, only embedded `data:` images show while remote or relative sources fall back to alt text or a placeholder without a network request, and text and code files above the rich-rendering size threshold fall back to plain text with a notice. +- Added a read-only **Files** canvas card and reader: open it from the empty-canvas context menu or the `Cmd/Ctrl+K` palette, root it at a live terminal session working directory or a folder picked in a native dialog, browse a directory tree that loads on demand, and read text/code as selectable monospace (bounded at 2 MB with a truncation marker and total size) or supported images (bounded at 25 MB) with explicit binary, unsupported, and too-large states. Quick open searches file names inside the active root, each card's root, bounds, open file, and expanded folders persist and restore with an unavailable state when the root is gone, and a new main-process `FileAccessService` enforces `realpath` containment so every listing, read, and search uses relative paths strictly inside registered roots with no write, rename, or delete capability. + ## 1.7.1 - Added built-in stable-release updates with explicit download and installation, active-session shutdown confirmation, and signed Sparkle updates on macOS. Install this first updater-enabled release manually; later compatible releases can be installed from the app. diff --git a/assets/mascot-pipeline/SKILL.md b/assets/mascot-pipeline/SKILL.md new file mode 100644 index 00000000..1bf5c06c --- /dev/null +++ b/assets/mascot-pipeline/SKILL.md @@ -0,0 +1,350 @@ +--- +name: canvastty-sprite-character +description: Create or improve an animated CanvasTTY mascot from a PNG or JPEG image, with image preparation, bundled motion examples, a live CanvasTTY browser preview, frame repair, scale review and approved installation. +--- + +# CanvasTTY mascot pipeline + +This is the complete single-page workflow. The portable pipeline and installed skill contain the same instructions, scripts, templates and motion examples. Read this page in full; no other Markdown page is required. Executable helpers and image/data assets remain separate files. All instructions are English; speak to the user and label the mascot menu in their language. + +## 1. Start the chat and show the workspace + +CanvasTTY accepts one PNG or JPG/JPEG, with or without a background, containing visible character pixels. Preserve a PNG unchanged as `references/character.png`; preserve original JPEG bytes as `references/character.jpg` and create the working `references/character.png` with corrected EXIF orientation and lossless PNG encoding of the decoded pixels. This encoding conversion does not redraw the character or remove its background. A background is accepted at intake, but animation frames must have real transparency. Reject corrupt, empty or unsupported input, not an opaque reference. Intake checks the actual PNG/JPEG format, a 20 MB limit and dimensions from 32 through 4096 pixels before launching. + +Find a Python 3.10+ executable that imports Pillow and NumPy before starting generation. Use the host-supplied quoted executable and pipeline paths. For standalone intake, run `scripts/prepare_input.py --image --project ` once. Do not run it again when the host has already prepared the working reference. Never overwrite an existing project. + +The CanvasTTY creation button explicitly starts the user's own Codex terminal in YOLO/bypass mode, with full file/command access and no approval prompts. Reuse the host's existing YOLO profile and browser integration; do not change unrelated global agent settings. The launch flag is `--dangerously-bypass-approvals-and-sandbox`. Pass the initial prompt before the variadic `--image ` argument so prompt text is not parsed as extra image paths. Attach exactly the uploaded image initially; load motion examples later when relevant. Full access does not replace creative choices, appearance approval or installation approval. + +At the beginning of the session, connect to CanvasTTY's browser and keep the work visible there throughout creation. The host starts `scripts/serve_preview.py --port 0` on loopback, opens its reported URL and supplies it to the chat. This workbench shows uploaded/prepared references, generated frames and the current built player; it refreshes as files change. Reuse the same tab. Before declaring browser control unavailable, discover the specialized `canvastty_browser` connector and its `browser_list_tabs`, `browser_observe`, `browser_activate_tab`, `browser_reload` and `browser_screenshot` tools. An empty general CUA inventory does not prove the CanvasTTY connector is unavailable. A new connector tab should use `engine: chromium` to be visible. Read the installed tool schemas before calling them. + +If the server stopped after an interruption, restart the supplied helper on a free port, read its URL, then navigate the existing CanvasTTY tab. Keep the process alive for the work session; bind only to `127.0.0.1`. The server serves only project images and plugin assets, not the entire user directory. Report unavailable browser access honestly; do not silently use an unrelated external browser. Keep preview availability separate from approval of what it shows. + +## 2. First question and mandatory source preparation + +Before asking for a name/action or generating art, ask in the user's language: + +> Which appearance should we use? 1. Keep the uploaded character's appearance and prepare it for animation. 2. Change its appearance first, review the result, then animate the approved version. + +Do not prescribe a body shape. For option 2, ask what to keep/change. Both options include the following mandatory preparation pass for JPEG photos and PNG drawings, including when the upload already has transparency. Explain that it creates a clean full-body 2D animation reference, preserves the uploaded identity and requires approval. JPEG-to-PNG intake conversion does not satisfy this image-generation step. Preserve the original and save the generated result separately as `references/prepared-character.png`. Resolve an ambiguous main subject before generation. Inspect the local reference before editing it, and use the available image-generation/editing tool with transparent output enabled. + +Use this preparation prompt, plus only the user's explicitly requested appearance changes: + +```text +Use the attached image as the reference for the main character. If the character +is cropped, complete the missing body parts to full height. Preserve the +recognizable appearance, proportions, facial expression, hairstyle, pose of the +visible body parts, colors, clothing and accessories. Continue missing details +logically from what is visible; do not add arbitrary elements. + +Redraw the character as a high-quality, detailed 2D illustration. Correct defects +in the source drawing: unclear contours, incorrect anatomy, merged details and +untidy shadows. Clearly separate hair, face, clothing and accessories. If glasses +are present, preserve their original shape, size and position. Draw both lenses, +the frame, bridge and temples clearly so they do not merge with the hair. Give +all other existing accessories the same careful treatment. + +Show one complete character from crown to feet, with a small margin around it. +The legs are straight and uncrossed; both feet rest evenly on the same horizontal +line as if standing on a flat surface. No extra limbs or fingers. + +Remove the entire background, other characters and extraneous objects. Keep only +the main character on a genuinely transparent PNG background with an alpha +channel. No halo, drawn floor, cast shadow, text or frame. +``` + +For an upright human character, the requested straight, uncrossed legs override the source leg pose; preserve other visible pose details. For a nonhuman design without human legs/feet, apply the equivalent stable support pose instead of inventing human anatomy. Keep worn accessories; remove unrelated scene objects. If removing an integral held object would change identity, clarify it. A later action can introduce its own props. + +Show the prepared full-resolution PNG in the workbench and chat. Check alpha, outline clarity, anatomy, glasses/accessories and retained design details. Ask for explicit approval; revise until approved or stop if the user rejects preparation. Do not silently substitute the original for the required pass. Save the approved result as `references/approved-character.png`. Use it to create/show the canonical idle frame for approval; the prepared image itself may become that frame if it already meets canvas and support requirements. Do not generate an action from an unapproved design. + +## 3. Establish the canonical reference and offer actions + +The accepted canonical frame fixes identity, physical scale, camera, palette, material and lighting. Reject blur, stains and unclear details here before producing many frames. Record visible invariants in one project `action-brief.md`: face structure, iris/pupil, obscured features, hair part/locks/ends/tone, skin marks, clothing colors/prints, fabric translucency, legwear, shoe shape/reflections, accessories and proportions. Keep redesign variants separate; the old upload never overrides a later approved design. + +Ask for a display name if missing. Then proactively offer a short, varied set of actions in the user's language rather than only asking an empty question. For example: + +> What should we animate next? We can make dancing, playing a handheld game, +> working at a laptop, smoking, listening to music, or drinking from a can. +> Or describe your own action, pose or scene, including objects, emotions and +> effects. For example: sitting on a cushion and coding on a laptop. + +Offer other relevant library patterns over subsequent rounds: resting/breathing and blinking, cheering, sadness, surprise, looking around, using a phone, taking a selfie, waving at the camera, yawning/stretching, dozing, a playful effect, eating a snack, hugging a plush object, tucking hair and waving. Adapt to the actual character and user; these are suggestions, not a mandatory catalog. After each completed action, offer a few unfinished choices plus a custom scene and “finish.” Do not create all suggested actions without selection. + +## 4. Use the bundled successful motion library + +Actual past sprite drawings are bundled in `examples/motion-library/`. They are visual motion evidence, not new character identity or a promise that every depicted detail is flawless. File names and metadata are neutral; no private paths, account details, original project IDs or named source mascots are required. + +- `set-a/motion.json` and its atlases contain the completed 152-drawing source set: dance, idle, cheer, sad, alert, phone, selfie, wavecam, sleepy, doze, look, smoke, energy, rainbow, game, snack, music, plush, laptop, tuck, wave and available entry/exit clips. +- `set-b/motion.json` contains 39 drawings: idle, seated-laptop staging (12 entrance drawings and a 12-drawing working cycle), and smoking (one entrance drawing, 12 main-cycle drawings over 3.55 seconds, and one exit drawing). The smoking sequence includes the corrected eighth main-cycle drawing, preserving its 0.20-second timing and neighboring grip continuity. The supplied reversed laptop exit is a structural example that must be checked for the new object's state. These completed sequences guide movement, facial acting, effects and prop contact; they do not exempt new frames from review. +- `set-c/motion.json` contains 16 drawings from a separately approved laptop scene: retrieval, bending/squatting/seating, opening, typing, thinking, idea reaction and exit. Its aligned atlas is the exact approved artwork. `examples/placement-regression/` contains an unaligned reconstruction from the same unchanged source drawings and a neutral diagnostic case. It is a placement failure example, never a positive motion reference. `python tests/placement_regression.py` distinguishes the drifting footwear rows from the aligned one-pixel range. Those regions, thresholds and tolerances apply only to this example; stable support does not prove correct anatomy, prop geometry or expressions. The reconstruction is not represented as an archived rejected package. No personal project paths, names or account IDs are required. +- The artwork was supplied by the project owner as workflow examples. Inclusion does not establish a new blanket license or ownership of depicted characters. Do not relabel it as public-domain/MIT artwork or publish invented attribution/permission claims. Example art stays out of each newly generated mascot plugin. + +Extract full-resolution reference frames and a timing/contact sheet using: + +```text +python scripts/reference_frames.py --set set-a --action smoke --output /motion-reference/smoke +python scripts/reference_frames.py --set set-b --action laptop:in --output /motion-reference/seated-entry +python scripts/reference_frames.py --set set-b --action smoke --output /motion-reference/alternate-smoke +``` + +Use a new empty output directory. The script crops existing atlas cells without redrawing them. Inspect the sequence sheet, then the relevant original-sized frames. The timing JSON preserves order/repetition. Do not attach every library image to the chat or every generation. + +For a known action, select its matching sequence and study phases, joint chains, contact, expression changes, effect origin and rhythm. For smoking, examine raise -> contact -> inhale -> move away -> exhale/cloud -> disperse -> ash tap, including the loop closure. Do not reduce the action to moving a hand. For seated work, examine reaching/holding -> lowering into the seat -> opening the laptop -> typing, thinking, reacting and recovering; check that its physical size stays consistent throughout. + +For a new action without a direct example, decompose it into known movement components. Combine a relevant reaching motion, seating transition, grip/contact, expression beat and effect progression. Explain which source frames guide each component; assemble a coherent new staging before adding intermediates. If a component has no suitable example, create and approve a few new key poses rather than pretending a match exists. The new accepted poses become local motion references. Do not copy the source character's face, clothes, palette, body proportions or unrequested props. + +Assign explicit roles in each generation prompt: + +1. Approved new character/canonical frame: identity, material, camera and physical scale; highest priority. +2. Accepted neighboring poses of that same new character: continuity and trajectory. +3. Selected library frames: motion/contact/emotion/effect pattern only. + +Attach only the few images relevant to that beat, within actual tool limits. Preserve identity anchors first if image slots are limited. Inspect local images before using them. Never generate a chain based only on the last generated frame; that compounds drift. A useful example can improve the first attempt but does not remove review. + +## 5. Plan the action and its mandatory phases + +Record the user's actual action, required phases and intended impression before generation. Use simple entry, main cycle and exit; note active limbs, body supports, prop shape/size/state and reference roles. Additional drawings connect accepted poses rather than introducing unnecessary choreography. Clarify whether a requested count covers the whole scene or only its main loop. + +For every required phase, record: phase ID, intended pose/expression, shoulder/elbow/wrist and palm orientation, prop state/contact, effect origin/progression, target clip, frame names and intended timing. Keep a brief phase matrix in `action-brief.md` and the machine-readable mapping in `character.json`: + +Before generation, write a facial beat map for anticipation -> effort/action -> contact -> reaction -> recovery: gaze, eyelid openness, brow shape and mouth state for each key pose. Facial identity (eye design, proportions, distinctive features) is fixed; the canonical frame's sleepy eyelids, O-shaped mouth or other momentary emotion is not. Specify the next expression explicitly rather than repeatedly asking for the canonical face. Record each persistent prop's fixed geometry and states, including grip, contact surface, hinge/pivot and occlusion. Inspect both visible and hidden limb chains in every active pose; a third hand or a hand without a connected arm is a rejection. + +```json +"required_phases": { + "smoke": [ + {"id": "inhale", "clip": "smoke", "frames": ["smoke-contact"]}, + {"id": "exhale", "clip": "smoke", "frames": ["smoke-exhale"]}, + {"id": "ash-tap", "clip": "smoke", "frames": ["smoke-ash"]}, + {"id": "recovery", "clip": "smoke", "frames": ["smoke-recover"]} + ] +} +``` + +This is an illustration; create real drawings and a complete mapping for the requested action. A repeatable phase belongs in the main clip when the user expects to see it every cycle. Entrance/exit frames do not count as missing main-loop content. The required phase list must retain the user's request. Never delete a phase because generation was difficult; changing scope needs an explicit user decision. A general “looks fine” does not retroactively authorize a silently omitted requirement. + +Start with compatible key poses, often three to six when suitable. Show a rough entry and main loop in the browser before producing many in-betweens. Mark drafts clearly. Correct incompatible anchors first. For each missing drawing, name its predecessor, successor and purpose: bridge, contact, accent or recovery. Numbering alone does not define playback order. + +## 6. Mandatory body AND object scale gate + +Run this gate after canonical approval, after every generated or repaired frame, after the key-pose set and before building/reviewing any completed action. Repeat it on the built atlas playback before approval. PNG dimensions alone are insufficient. + +1. Confirm the common source canvas and camera. Compare the canonical and neighboring drawings at exactly one display scale; never auto-fit each image independently. +2. Compare physical head size, torso width/length and relevant limb segments. Record actual landmark measurements in the brief for suspect frames and the visual reason for any change. Sitting lowers the head; bending and foreshortening change projected measurements. Do not force all heads to the same height or impose one pixel ratio on different perspectives. +3. Track real support contacts (feet, seated hips, hands) and trajectories. Distinguish whole-image translation from a wrongly enlarged body. Matching feet alone does not fix a changed head/body size. Smoke, symbols, hair and falling objects are not floor markers. +4. Compare every prop from its first appearance through entrance, main loop and exit: phones, cups, cans, cigarettes, toys, tools, furniture, weapons, laptops and any custom object. Record proportions relative to the same hand/body landmarks. A closed laptop behind the back must not become physically larger when opened on the lap; this is one example of the rule for all objects. Perspective changes projected geometry, not the object's actual dimensions. Check fixed proportions, joints/hinges and correct overlaps; no duplicate edges, floating contacts or unexplained growth. A planned physical change, such as inflation or unfolding, must be explicitly staged rather than treated as accidental drift. +5. Compare previous -> current -> next and the last -> first seam, using contact sheets/overlays as diagnostic views. A global size jump fails the gate even when the individual drawing looks good. Record a concrete scale/prop review result, not merely “same resolution.” +6. Repair the responsible layer before continuing: a source proportion error needs regeneration/targeted redraw; a translation needs a justified whole-image offset; wrong atlas addressing needs a build/player fix. Never independently stretch a frame to fill its bounding box or splice body parts to conceal the mismatch. + +Minor file-size discrepancies can be corrected with transparent padding when the physical drawing scale already matches. The historical seated-laptop import accepted approximately one pixel of width/two of height difference and applied a small whole-image floor offset; those were project-specific values, not global tolerances. Preserve exact source pixels and check clipping. Use explicit ground anchors only for known grounded poses, keeping airborne frames elevated. Do not align the whole character using an effect below the feet. + +Diagnose discontinuity before adding drawings. Inspect source -> explicit placement transform -> final atlas cell -> actual playback. A bridge cannot repair a whole drawing placed on a different floor. A progressive crown position is insufficient when footwear bounces. Track independent crown/face, shoulders, hips, footwear, hands and prop landmarks. For grounded poses, unexplained support movement fails review even when the head trajectory looks plausible. Preserve intended bending, sitting and facial acting; do not force constant crown height. + +Define support regions visually around real footwear, hips or other contact. Record the region and a diagnostic alpha threshold suitable for that artwork; faint stray alpha, hair and effects are not support. Check every nonzero-alpha pixel separately for clipping safety. A diagnostic threshold must never remove soft artwork. If an explicit translation would clip, choose a feasible common floor or justified common transparent padding and rebuild. Never crop real art or disable the clipping guard. + +After the key-pose build and after every final art/placement/order/timing change, maintain `continuity.json` and run `python scripts/review_continuity.py /character.json`. Annotate every active drawing using the current source hash from `build-report.json`. Coordinates and lengths are measured in the common SOURCE canvas, not auto-fitted thumbnail pixels. Include at least independent body landmarks and body sizes, plus measured dimensions/hinge/grip landmarks for every visible persistent prop. Use stable measurement names across neighboring drawings. Explain occlusion rather than inventing hidden measurements. Example record (replace every value with actual inspected measurements): + +```json +{ + "limits": {"support_px": 2, "landmark_px": 24, "size_fraction": 0.05}, + "frames": { + "action-01": { + "source_sha256": "", + "supports": [{"name": "footwear", "region": [100, 800, 500, 960], "alpha_threshold": 128, "grounded": true}], + "landmarks": {"face": [300, 180], "hips": [300, 580], "hand": [340, 520], "prop_hinge": [320, 540]}, + "sizes": {"head_width": 90, "torso_length": 260, "prop_base_width": 180}, + "anatomy": "", + "contact": "", + "face": "" + } + } +} +``` + +The example limits, threshold and regions are not universal defaults. Choose and explain useful limits for the project's canvas, perspective and movement; do not inflate them to hide a defect. An unsupported pose uses `"supports": []` and a nonempty `support_note`. Airborne/lifted contacts must be marked appropriately. The helper measures source and actual atlas support edges in declared regions, records applied offsets, and flags neighbor/seam/boundary changes in support, landmark trajectory and measured sizes. Source annotations must be updated after source edits. The report does not detect anatomy, infer trustworthy measurements or prove artistic success. Its flags require a concrete observed explanation for intentional motion/perspective, or a repair and new report. Support-only success never substitutes for body, object and facial review. + +## 7. Generate and repair exact beats + +Keep one concise frame brief with output filename, source references and their roles, both neighboring poses, phase, duration, exact change, retained details and rejection criteria. Specify image-left/image-right. Good prompts constrain the movement while retaining expressive face/body motion. + +```text +Create one complete transparent frame of the approved character. +Reference A fixes this character's identity, materials, colors and proportions. +Reference B fixes its canonical camera, physical scale and source canvas. +References C/D are accepted neighboring poses of this character. +Reference E, if provided, is a motion example only: do not copy its identity. +Beat: [one named phase and its progression from previous toward next]. +Supports: [actual body contacts]. Active motion: [trajectory and landmarks]. +Hand: [shoulder-elbow-wrist chain, palm side, grip, visible/occluded fingers]. +Object: [count, shape, fixed physical size, state, contact and overlap]. +Face: [gaze, eye/iris/pupil, brows, mouth, expression and progression]. +Effect: [source, direction, development and disappearance, if requested]. +Preserve [specific hair locks/tone, skin, clothing marks, fabric translucency, +shoe reflections, accessories, unchanged parts and lighting]. +Keep the full character, prop and effect inside the common transparent canvas. +Reject [specific risks]. Do not add new hair, accessories or background. +``` + +For an in-between, say exactly which part progresses how far between the accepted endpoints. Preserve grip/palm side unless the planned rotation changes them. For a repair, use the flawed frame for pose/composition, the canonical frame for identity/material and both neighbors for trajectory. Name only the defects to fix and preserve accepted features. Save a distinct replacement, inspect it, then change exact config references. If local repair damages anatomy/material, regenerate a coherent full frame from accepted anchors. + +Check the complete shoulder -> elbow -> forearm -> wrist -> palm -> fingers -> object chain. Hidden fingers need not be drawn. Adapt anatomy checks to the actual human, stylized or nonhuman design. Check lips/fingers/knees/support contact and object occlusion across the entire action when a defect recurs. + +Materials are invariants as well as colors: stable hair/skin/cloth patches must retain tone, texture, density and translucency. Natural reflections move with a pose; a global brightness shift, darker stockings or plastic-looking fabric is a defect. Check shoe highlights, iris/pupil and glasses as carefully as the hair. Preserve major hair locks, ends and length; no extra masses appearing near knees or elsewhere. Do not remove stains by flattening or blurring the whole material. + +Every action needs a deliberate facial-performance plan: gaze, eyes, brows and mouth develop through anticipation, action, peak and recovery. Include suitable emotional-effect beats in the action brief, guided by the successful library examples: an idea glint, a thought symbol, a joy accent, rhythm marks or another fitting effect. Show the proposed effect in the key-pose preview; adapt to the user's requested tone and retain an explicit no-effects preference. Do not put every symbol in every frame or cover the face with effects. Effects support readable facial acting; a changed arm or animated symbols over an unintentionally frozen face do not satisfy an expressive scene. For a faceless character use its designed equivalents, such as eye lights, ears or posture. Stable supports do not mean a frozen body. Breathing, gaze, facial movement and appropriate follow-through remain alive. + +Effects must have a physical or expressive source. Exhaled smoke starts at the mouth; an ember trail starts at the tip; ash separates from the tip and falls; a thought accent matches a visible facial beat. Preserve soft alpha. Check edges on light and dark backgrounds; invisible RGB under zero alpha is not a visible halo. Do not add an unrequested floor, shadow or effect. + +After two unsuccessful attempts at the same defect, change the references, prompt or staging and explain what remains wrong. Do not repeat blindly, remove requested phases or finalize a defective action. Respect tool refusals/capability limits and never claim missing artwork exists. + +## 8. Timing, counts and live review + +Budget seconds for phases before assigning holds. A bridge is usually shorter than a readable contact or emotional peak, but no universal duration applies. Neighboring effect drawings share a phase budget; do not give each a long pause. Recalculate totals when adding frames. Coherent typing/breathing/sway mini-cycles may repeat, but repeated steps are not new drawings and cannot replace missing movement. + +The completed 152-drawing reference provides these main-loop planning examples. Entrance/exit are excluded; counts are estimates, not automatic quality thresholds: + +| Pattern | Unique drawings | Steps | Approximate seconds where measured | +| --- | ---: | ---: | ---: | +| Dance | 20 | 20 | 3.28 | +| Idle breathing/blink | 7 | 7 | 2.82 | +| Cheer | 2 | 6 | | +| Sad reaction | 2 | 2 | | +| Alert | 1 | 1 | | +| Phone | 2 | 4 | | +| Selfie | 2 | 3 | | +| Camera wave | 1 | 1 | | +| Yawn/stretch | 3 | 3 | | +| Doze/wake | 2 | 4 | | +| Look around | 3 | 3 | | +| Smoking | 12 | 12 | 3.55 | +| Drink from a can | 6 | 6 | 5.30 | +| Short effect | 6 | 12 | | +| Handheld game | 4 | 6 | | +| Snack | 11 | 12 | 2.90 | +| Music | 10 | 17 | 3.89 | +| Plush hug | 9 | 9 | 2.30 | +| Laptop | 15 | 19 | 4.29 | +| Hair tuck | 5 | 5 | | +| Brief wave | 1 | 1 | | + +A one-drawing reaction is a held pose; add motion when animation is requested. Always report whole-scene drawings, main-loop drawings, repeated steps and seconds separately. A 12-drawing entrance plus 12-drawing main loop is 24 drawings if disjoint. Reusing the entrance for a physically valid reversed exit adds no new drawings. Never reverse irreversible consumption/spilling to invent an exit. + +Keep generated frames visible in the browser as they arrive. After accepted key poses, intermediate fills, repairs and timing changes, rebuild the current action and observe it in that same browser. The workbench refreshes files/builds automatically; verify its displayed project/build ID and the selected action. Use `/plugin/index.html?action=` for a direct player view if needed. After interruption, verify the loopback server still responds. Do not confuse an old installed canvas card with the preview. + +Review four distinct views: full-size source against canonical; previous/current/next at shared scale; close-ups of hands/props/materials/effects; actual built main loop at card size, separately from entry/exit. Watch at least two main loops. A finite demo containing entrance/loop/exit can conceal that the main loop only contains two drawings; show and report the main loop itself. A successful tool click, file count or green mechanical QA is not visual proof. + +Cover EVERY built playback step deterministically. The workbench's Deterministic atlas review pauses on a named clip and zero-based step; Previous/Next and the step selector cannot skip short holds. It displays build ID, drawing name, duration, previous/current/next actual atlas cells at one shared scale, and the current source before placement. Support lines/offsets come from the current continuity report. Use `scene/` to traverse idle, full entrance, two main loops, full exit and idle, including cross-clip boundaries. An individual clip wraps within itself, so inspect actual entrance/exit boundaries through the scene option. Also inspect full-size source and the live player; deterministic cells do not prove timing or the live runtime's clip selection. Capture real-time playback when available to compare with the ordered sheet; sparse screenshots alone cannot establish complete coverage. If capture is unavailable, report that limitation and still step through every frame and observe the full sequence live. + +## 9. Build configuration and commands + +Keep the upload, approved source, frames, reports and action brief outside `plugin/`. Start from `examples/character.example.json`, set the real ID/name, and use project-relative paths. Do not copy developer paths or example mascot IDs. Script paths below are relative to the pipeline; project paths belong to this user. + +```text +python scripts/qa_frames.py /character.json +python scripts/scaffold_plugin.py /character.json +python scripts/build_sprite.py /character.json +python scripts/preview_animation.py /character.json --action +python scripts/review_continuity.py /character.json +``` + +Scaffold only an empty plugin directory. Preserve a customized existing player/page during updates. Rebuild after art, timing, order or metadata changes. Draft QA/build/preview is allowed without final approval. + +- `clips` maps IDs to `[frame-name, seconds]` steps. Repeated mini-cycles use `{"seq": [["frame", 0.2]], "times": 3}`. Durations are finite positive seconds and repeat counts positive integers. +- `action:in`, `action`, `action:out` separate entry, main loop and exit. Prefer explicit clips. Legacy `to-action`/`from-action` frames are included by QA/build if present. `deck` lists selectable actions; `labels` uses the user's UI language. `loops` controls finite repeats in All actions, and `follow` optional related actions. +- The selected action enters once, repeats its main clip until changed, then exits at a compatible clip boundary. Latest selection during transitions wins. Same selection does not restart. Do not return to idle after every main cycle. Design shorter transitions if quicker switching is needed; do not cut into incompatible poses. +- `floor_frame` identifies the canonical reference, reviewed even outside playable clips. `scale: 1` preserves source detail. Larger positive integers downsample every drawing equally with premultiplied alpha. One common crop/scale retains the sequence's full extent. +- Coordinates/alpha are preserved by default. No automatic chroma key or alpha threshold removes soft smoke. Optional `floor_y` and `ground_anchors` supply actual grounded contact rows, for example `"floor_y": 900, "ground_anchors": {"wave-02": 897}` shifts that whole frame down three pixels. `airborne` frames are excluded. Clipping a translated drawing fails. No per-frame stretching/warping is allowed. +- `cols`, `rows_per_sheet`, `margin_x`, `pad_bottom` govern packing. Atlas sides are capped at 4096 pixels; oversized cells need less padding or a justified shared scale. Keep host size limits. Generated `shadow` defaults to false. +- `name-fixed.png` takes precedence over `name.png` for compatibility. QA/build reports record the exact resolved source. Prefer explicit replacement names and retain accepted originals. + +`build-report.json` records config/source/package hashes, `build_id`, per-clip drawings/steps/seconds and source-canvas/crop/common-scale/per-frame placement transforms. `frames.js` provides index-to-name mapping; the player exposes the build ID (`document.documentElement.dataset.buildId`). The ID includes current source, config, runtime and atlas asset hashes. Changing art, placement, order, timing or package assets invalidates previous build-specific approval. Preview/finalization reject stale package evidence. The atlas-based WebP demonstration repeats the whole entry/cycles/exit when its file loops; the live player's selected mode has different, intentional behavior. The source PNG review remains separate. Ordered sheets show names, atlas indices, holds and build ID at a common scale; report entrance, main-loop and exit counts/seconds separately. + +## 10. Approval and explicit installation + +Preserve the user's complete agreed action/phase configuration as `character-full-plan.json` before reducing `character.json` for a partial delivery. Keep all requested actions and phase IDs in that full plan, including unfinished work. Update it only when the user explicitly changes scope, and record the reason in the brief. Never remove a squat, transition, object interaction or other requirement merely to make validation green. A new full-plan hash needs new build-specific user acceptance. + +After actual full-size and playback review, record a specific QA note: + +```text +python scripts/qa_frames.py /character.json --approve "" +``` + +Create `review.json` for the exact current `build_id`. It must include `user_approved: true` only after the user approves that shown build; `checks` with nonempty observation notes for `source`, `scale`, `neighbors`, `materials_contact`, `effects`, `main_loop`, `boundaries`, `browser`; and `phases` mapping each action and each required phase ID to its observed result. Record why a check is inapplicable where genuinely so; do not fabricate an observation. Example structure: + +```json +{ + "build_id": "", + "user_approved": true, + "installation_approved": false, + "checks": { + "source": "", + "scale": "", + "neighbors": "", + "materials_contact": "", + "effects": "", + "main_loop": "", + "boundaries": "", + "browser": "" + }, + "phases": {"": {"": ""}}, + "coverage": {"": {"steps": [0, 1, 2], "note": ""}}, + "playback": {"": {"main_loops": 2, "entrance": true, "exit": true, "note": ""}}, + "continuity_manifest_sha256": "", + "continuity_report_sha256": "", + "continuity_decisions": {"": ""}, + "unresolved": [] +} +``` + +This structure records review evidence; filled text alone cannot prove artistic quality. Never autofill positive claims from mechanical QA. The finalizer checks mapped phase presence, review coverage, current hashes and build approval. Retain every user-required phase; deleting a requirement to pass validation is invalid. + +Add `observations` for every key in `checks`. Each entry has `mode`, `evidence` (the actual inspected view/capture and finding), and `build_id` for `observed_current_build`. New source art, scale, neighboring steps, main-loop timing, boundaries and browser runtime are different observations; an unchanged atlas does not prove new timing or placement. `reused_unchanged_pixels` is allowed only for `source`, `materials_contact` or `effects`; record `previous_build_id`, the prior observation's evidence, and `source_sha256` mapping every source drawing claimed unchanged to its exact current hash. This reuses only that stated pixel evidence, never user approval or live playback. `unverified` is not final approval. The finalizer rejects missing/stale provenance, but it cannot establish whether an observation is truthful: actually inspect the views. Do not manufacture coverage notes or bulk-dismiss flags with generic descriptions of poses. When `character-full-plan.json` exists, include its exact `full_plan_sha256` in the review. Final delivery must retain every full-plan action/phase. + +### Explicitly accepted draft delivery + +If the user requests the current incomplete result for inspection, do not force it through final approval. Explain its exact build, missing phases and known defects first. Preserve the full plan and create separate `draft-review.json` with the same checks, provenance, coverage, phases, playback and continuity hashes as above, plus: + +```json +{ + "build_id": "", + "user_approved": false, + "draft_approved": true, + "installation_approved": true, + "full_plan_sha256": "", + "deferred_phases": ["/"], + "unresolved": [""], + "limitations": ["", ""] +} +``` + +Merge these fields into the complete evidence record, not a standalone minimal approval. `deferred_phases` lists every absent full-plan phase in full-plan action/phase order; `limitations` contains every deferred ID and every unresolved concern verbatim, with explanatory context where useful. `draft_approved` and `installation_approved` reflect the user's explicit acceptance of this identified draft and its disclosed limits. Keep `review.json` untouched; do not copy old approval into it. An unresolved continuity flag remains its exact flag ID in `unresolved` and `limitations`, rather than an invented positive decision. A check that cannot be observed uses provenance mode `unverified` and its check key in both lists; state why and what remains to inspect. Every available built step and live sequence must still be reviewed where accessible. Structural/source/package/hash QA remains mandatory; draft mode does not allow missing files, invalid geometry, stale builds or arbitrary runtime code. + +If live playback itself cannot be observed, keep `main_loop` in both limitation lists and record that action's `playback` as `{"mode": "unverified", "note": ""}`. Do not invent loops or transition observations. This exception applies only to a disclosed, user-accepted draft; final delivery still requires the real live sequence. + +Run `python scripts/finalize_mascot.py /character.json --draft` to record an accepted draft without requesting installation; use `--draft --install-approved` only with that draft's separate installation consent. The host verifies the same mode with `--draft --verify-only --install-approved`. A draft handoff carries `delivery: draft` and visible `limitations`; it never means the complete animation is finally approved. Continue deferred work against the preserved full plan. Final delivery uses `review.json`, fresh current-build evidence and no unresolved concerns, without `--draft`. + +`coverage` must list every zero-based step exactly once for every configured clip, including idle, full entrance and exit, not only unique drawings. `playback` records at least two actual repeating main loops per selectable action and full entrance/exit observation where present. Keep unresolved concerns visible while working; an approved final result requires an explicitly empty `unresolved` list. Every current continuity flag needs a substantive observed decision; do not use boilerplate to dismiss unexplained growth, support drift, disconnected limbs or impossible hinges. The finalizer rejects stale continuity annotations/reports and missing step coverage. Re-run QA after the final placement/config/source change, rebuild, measure the final atlas, then make new visual findings for that build. Never copy approval of an earlier draft forward. + +Report machine checks, full-size art inspection, all-step/adjacent-frame inspection, live playback, user approval, host-installed ID and loaded runtime ID as separate facts. An observed `installedBuildId` confirms the host's installed package; it does not independently establish drag/resize/zoom/close or that an already open runtime loaded the same build. Check those separately when reporting them. + +Run `python scripts/finalize_mascot.py /character.json` to produce `status: reviewed`, which does not request installation. Then ask separately whether to install/replace this project/build on the canvas, unless the user has already explicitly authorized installation of this reviewed result. Show project identity and build ID. “Finish the animation” or appearance approval alone is not installation approval. + +After the user's installation decision, set `installation_approved: true` for that build and run: + +```text +python scripts/finalize_mascot.py /character.json --install-approved +``` + +This writes `mascot-result.json` with `ready_for_host`, matching `buildId`, `installationApproved: true`, actions, plugin directory and transparent/resizable presentation. CanvasTTY verifies it, installs/replaces only the same project's package, saves the previous installed version, and reloads its card. An old `installedAt` must not prevent an approved update. Host validation uses `--verify-only --install-approved` without rewriting the requested result. + +Use only this finalizer and CanvasTTY's registered local-mascot installer. Never write `ready_for_host` by hand, copy a package into the host's plugins directory, edit its registry, reset the project record on disk or rewrite packaged application files to bypass review. Package files alone do not register a plugin. An existing unregistered destination is preserved and reported as a collision; establish its owner and obtain approval for any archive/move before recovery. Do not overwrite an unknown directory. + +After a rejection, inspect the project's `mascot-project.json`, `mascot-result.json` and `installation-error.log`. State the stage (creation, handoff, package/review validation, registration or card load), short root cause, requested build, installed build and registered plugin as separate facts. `window.canvasTTY.mascots.list()` in the trusted host renderer exposes these fields; do not attempt this host-only API from an untrusted plugin iframe. The full log retains stderr/traceback; do not rely on a truncated command prefix. Correct the underlying issue, regenerate a current approved handoff through the finalizer, and let the host retry the changed result even if the project was previously `failed`. An unchanged rejected result is not polled repeatedly. Use Settings > Mascots > Retry installation (or the trusted host's `mascots.retry(projectId)`) for an explicit retry, including transient failures, without restarting or editing in-memory state through disk. A retry queued during a check is handled on the following poll. Registry confirmation still does not prove the open card's runtime build; inspect that independently. If runtime/browser access is unavailable, report it as unverified rather than promising success. + +`ready_for_host` is a request, not proof of installation. Confirm the loaded runtime build matches the reviewed build. Check the actual card's transparent background, drag by the character, menu interaction outside drag regions, proportional resizing down to small sizes, hover-only resize corner (available for touch), close button and visible mascot when zoomed out. The parent card belongs to CanvasTTY; iframe CSS cannot make the parent transparent. Report any unobserved host behavior as unverified. Do not patch application files as a generic mascot-creation fallback. + +## 11. Continue safely and preserve the result + +On resume, read the project brief, actual config, accepted references and current files. Verify preview server/build, then continue missing work. Do not rerun obsolete experiment scripts just because their filenames sound relevant. New art/order/timing/package changes invalidate dependent review and installation approval. + +Keep one concise brief with source provenance, exact prompts/reference roles, accepted/rejected frames and reasons, phase order/timing, measurements for scale repairs, current preview and unresolved defects. Preserve original art and previous accepted versions. Do not modify this global skill during ordinary mascot creation; improvements belong to a separate user-requested task. + +The result is complete only when the requested action set, scale gate, visual review and agreed delivery stage are complete. Known defects stay unresolved until rechecked. Technical QA cannot guarantee perfect generative output; it prevents structural errors while source/sequence/browser review decides whether the art works. diff --git a/assets/mascot-pipeline/examples/character.example.json b/assets/mascot-pipeline/examples/character.example.json new file mode 100644 index 00000000..d5177692 --- /dev/null +++ b/assets/mascot-pipeline/examples/character.example.json @@ -0,0 +1,21 @@ +{ + "id": "new-mascot", + "name": "New Mascot", + "description": "An animated mascot for CanvasTTY.", + "frames_dir": "frames", + "plugin_dir": "plugin", + "floor_frame": "idle-01", + "airborne": [], + "scale": 1, + "cols": 6, + "rows_per_sheet": 5, + "pad_bottom": 48, + "margin_x": 40, + "shadow": false, + "clips": { + "idle": [["idle-01", 0.45]] + }, + "deck": [], + "required_phases": {}, + "labels": {} +} diff --git a/assets/mascot-pipeline/examples/motion-library/set-a/atlas-0.webp b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-0.webp new file mode 100644 index 00000000..24ef94d5 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-0.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-a/atlas-1.webp b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-1.webp new file mode 100644 index 00000000..1e589417 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-1.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-a/atlas-2.webp b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-2.webp new file mode 100644 index 00000000..f6f8b440 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-2.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-a/atlas-3.webp b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-3.webp new file mode 100644 index 00000000..6c29c56f Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-3.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-a/atlas-4.webp b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-4.webp new file mode 100644 index 00000000..d870c865 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-4.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-a/atlas-5.webp b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-5.webp new file mode 100644 index 00000000..56e88f80 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-a/atlas-5.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-a/motion.json b/assets/mascot-pipeline/examples/motion-library/set-a/motion.json new file mode 100644 index 00000000..4b7826fe --- /dev/null +++ b/assets/mascot-pipeline/examples/motion-library/set-a/motion.json @@ -0,0 +1,882 @@ +{ + "cols": 6, + "perSheet": 30, + "cell": [ + 508, + 792 + ], + "sheets": [ + { + "file": "atlas-0.webp", + "rows": 5 + }, + { + "file": "atlas-1.webp", + "rows": 5 + }, + { + "file": "atlas-2.webp", + "rows": 5 + }, + { + "file": "atlas-3.webp", + "rows": 5 + }, + { + "file": "atlas-4.webp", + "rows": 5 + }, + { + "file": "atlas-5.webp", + "rows": 1 + } + ], + "clips": { + "dance": [ + [ + 0, + 0.26 + ], + [ + 1, + 0.1 + ], + [ + 2, + 0.26 + ], + [ + 3, + 0.1 + ], + [ + 4, + 0.26 + ], + [ + 5, + 0.1 + ], + [ + 6, + 0.1 + ], + [ + 7, + 0.26 + ], + [ + 8, + 0.1 + ], + [ + 9, + 0.1 + ], + [ + 10, + 0.26 + ], + [ + 11, + 0.1 + ], + [ + 12, + 0.26 + ], + [ + 13, + 0.1 + ], + [ + 14, + 0.1 + ], + [ + 15, + 0.26 + ], + [ + 16, + 0.1 + ], + [ + 17, + 0.26 + ], + [ + 18, + 0.1 + ], + [ + 19, + 0.1 + ] + ], + "idle": [ + [ + 20, + 0.45 + ], + [ + 21, + 0.45 + ], + [ + 22, + 0.45 + ], + [ + 23, + 0.12 + ], + [ + 24, + 0.45 + ], + [ + 25, + 0.45 + ], + [ + 26, + 0.45 + ] + ], + "cheer": [ + [ + 27, + 0.3 + ], + [ + 28, + 0.3 + ], + [ + 27, + 0.3 + ], + [ + 28, + 0.3 + ], + [ + 27, + 0.3 + ], + [ + 28, + 0.3 + ] + ], + "sad": [ + [ + 29, + 1.2 + ], + [ + 30, + 1.4 + ] + ], + "alert": [ + [ + 31, + 1.6 + ] + ], + "phone": [ + [ + 32, + 1.2 + ], + [ + 33, + 0.9 + ], + [ + 32, + 0.9 + ], + [ + 33, + 1 + ] + ], + "selfie": [ + [ + 34, + 1.2 + ], + [ + 35, + 1.2 + ], + [ + 34, + 0.8 + ] + ], + "wavecam": [ + [ + 36, + 1.6 + ] + ], + "sleepy": [ + [ + 37, + 1.3 + ], + [ + 38, + 1 + ], + [ + 39, + 0.9 + ] + ], + "doze": [ + [ + 40, + 1.6 + ], + [ + 41, + 0.6 + ], + [ + 40, + 1.4 + ], + [ + 41, + 0.9 + ] + ], + "look": [ + [ + 42, + 1.1 + ], + [ + 43, + 1.1 + ], + [ + 44, + 1.4 + ] + ], + "smoke": [ + [ + 45, + 0.4 + ], + [ + 46, + 0.2 + ], + [ + 47, + 0.2 + ], + [ + 48, + 0.25 + ], + [ + 49, + 0.4 + ], + [ + 50, + 0.35 + ], + [ + 51, + 0.2 + ], + [ + 52, + 0.2 + ], + [ + 53, + 0.3 + ], + [ + 54, + 0.4 + ], + [ + 55, + 0.35 + ], + [ + 56, + 0.3 + ] + ], + "energy": [ + [ + 57, + 1 + ], + [ + 58, + 0.5 + ], + [ + 59, + 0.7 + ], + [ + 60, + 0.9 + ], + [ + 61, + 0.8 + ], + [ + 62, + 1.4 + ] + ], + "rainbow": [ + [ + 63, + 0.8 + ], + [ + 64, + 0.35 + ], + [ + 65, + 0.18 + ], + [ + 66, + 0.18 + ], + [ + 65, + 0.18 + ], + [ + 66, + 0.18 + ], + [ + 65, + 0.18 + ], + [ + 66, + 0.18 + ], + [ + 65, + 0.18 + ], + [ + 66, + 0.18 + ], + [ + 67, + 0.6 + ], + [ + 68, + 1.2 + ] + ], + "game": [ + [ + 69, + 0.9 + ], + [ + 70, + 0.4 + ], + [ + 71, + 0.4 + ], + [ + 70, + 0.4 + ], + [ + 71, + 0.4 + ], + [ + 72, + 1.2 + ] + ], + "snack": [ + [ + 73, + 0.3 + ], + [ + 74, + 0.2 + ], + [ + 75, + 0.2 + ], + [ + 76, + 0.2 + ], + [ + 77, + 0.3 + ], + [ + 78, + 0.2 + ], + [ + 79, + 0.3 + ], + [ + 80, + 0.25 + ], + [ + 79, + 0.25 + ], + [ + 81, + 0.2 + ], + [ + 82, + 0.2 + ], + [ + 83, + 0.3 + ] + ], + "music": [ + [ + 84, + 0.3 + ], + [ + 85, + 0.18 + ], + [ + 86, + 0.18 + ], + [ + 87, + 0.18 + ], + [ + 86, + 0.18 + ], + [ + 85, + 0.18 + ], + [ + 86, + 0.18 + ], + [ + 87, + 0.18 + ], + [ + 86, + 0.18 + ], + [ + 88, + 0.25 + ], + [ + 89, + 0.25 + ], + [ + 88, + 0.25 + ], + [ + 89, + 0.25 + ], + [ + 90, + 0.3 + ], + [ + 91, + 0.25 + ], + [ + 92, + 0.3 + ], + [ + 93, + 0.3 + ] + ], + "plush": [ + [ + 94, + 0.3 + ], + [ + 95, + 0.2 + ], + [ + 96, + 0.3 + ], + [ + 97, + 0.2 + ], + [ + 98, + 0.2 + ], + [ + 99, + 0.3 + ], + [ + 100, + 0.25 + ], + [ + 101, + 0.25 + ], + [ + 102, + 0.3 + ] + ], + "laptop": [ + [ + 103, + 0.18 + ], + [ + 104, + 0.18 + ], + [ + 105, + 0.18 + ], + [ + 106, + 0.18 + ], + [ + 103, + 0.18 + ], + [ + 104, + 0.18 + ], + [ + 105, + 0.18 + ], + [ + 106, + 0.18 + ], + [ + 107, + 0.2 + ], + [ + 108, + 0.2 + ], + [ + 109, + 0.25 + ], + [ + 110, + 0.3 + ], + [ + 111, + 0.2 + ], + [ + 112, + 0.3 + ], + [ + 113, + 0.2 + ], + [ + 114, + 0.3 + ], + [ + 115, + 0.3 + ], + [ + 116, + 0.3 + ], + [ + 117, + 0.3 + ] + ], + "dance:in": [ + [ + 118, + 0.18 + ] + ], + "dance:out": [ + [ + 119, + 0.18 + ] + ], + "cheer:in": [ + [ + 120, + 0.18 + ] + ], + "cheer:out": [ + [ + 121, + 0.18 + ] + ], + "phone:in": [ + [ + 122, + 0.18 + ] + ], + "phone:out": [ + [ + 123, + 0.18 + ] + ], + "selfie:in": [ + [ + 124, + 0.18 + ] + ], + "selfie:out": [ + [ + 125, + 0.18 + ] + ], + "wavecam:in": [ + [ + 126, + 0.18 + ] + ], + "wavecam:out": [ + [ + 127, + 0.18 + ] + ], + "sleepy:in": [ + [ + 128, + 0.18 + ] + ], + "sleepy:out": [ + [ + 129, + 0.18 + ] + ], + "doze:in": [ + [ + 130, + 0.18 + ] + ], + "doze:out": [ + [ + 131, + 0.18 + ] + ], + "look:in": [ + [ + 132, + 0.18 + ] + ], + "look:out": [ + [ + 133, + 0.18 + ] + ], + "smoke:in": [ + [ + 134, + 0.18 + ] + ], + "smoke:out": [ + [ + 135, + 0.18 + ] + ], + "energy:in": [ + [ + 136, + 0.18 + ] + ], + "energy:out": [ + [ + 137, + 0.18 + ] + ], + "rainbow:in": [ + [ + 138, + 0.18 + ] + ], + "rainbow:out": [ + [ + 139, + 0.18 + ] + ], + "game:in": [ + [ + 140, + 0.18 + ] + ], + "game:out": [ + [ + 141, + 0.18 + ] + ], + "snack:in": [ + [ + 142, + 0.18 + ] + ], + "snack:out": [ + [ + 143, + 0.18 + ] + ], + "music:in": [ + [ + 144, + 0.18 + ] + ], + "music:out": [ + [ + 145, + 0.18 + ] + ], + "plush:in": [ + [ + 146, + 0.18 + ] + ], + "plush:out": [ + [ + 147, + 0.18 + ] + ], + "laptop:in": [ + [ + 148, + 0.18 + ] + ], + "laptop:out": [ + [ + 149, + 0.18 + ] + ], + "tuck": [ + [ + 20, + 0.45 + ], + [ + 21, + 0.45 + ], + [ + 150, + 1.6 + ], + [ + 25, + 0.45 + ], + [ + 26, + 0.45 + ] + ], + "wave": [ + [ + 151, + 1.4 + ] + ] + } +} diff --git a/assets/mascot-pipeline/examples/motion-library/set-b/atlas-0.webp b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-0.webp new file mode 100644 index 00000000..4d964adb Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-0.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-b/atlas-1.webp b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-1.webp new file mode 100644 index 00000000..3e6ea822 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-1.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-b/atlas-2.webp b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-2.webp new file mode 100644 index 00000000..f9a533c6 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-2.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-b/atlas-3.webp b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-3.webp new file mode 100644 index 00000000..12fd739b Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-3.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-b/atlas-4.webp b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-4.webp new file mode 100644 index 00000000..975eaa12 Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-b/atlas-4.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-b/motion.json b/assets/mascot-pipeline/examples/motion-library/set-b/motion.json new file mode 100644 index 00000000..170e79f4 --- /dev/null +++ b/assets/mascot-pipeline/examples/motion-library/set-b/motion.json @@ -0,0 +1,191 @@ +{ + "cols": 4, + "perSheet": 8, + "cell": [ + 861, + 1668 + ], + "sheets": [ + { + "file": "atlas-0.webp", + "rows": 2 + }, + { + "file": "atlas-1.webp", + "rows": 2 + }, + { + "file": "atlas-2.webp", + "rows": 2 + }, + { + "file": "atlas-3.webp", + "rows": 2 + }, + { + "file": "atlas-4.webp", + "rows": 2 + } + ], + "clips": { + "smoke:in": [[1, 0.18]], + "smoke": [[2, 0.4], [3, 0.2], [4, 0.2], [5, 0.25], [6, 0.4], [7, 0.35], [8, 0.2], [9, 0.2], [10, 0.3], [11, 0.4], [12, 0.35], [13, 0.3]], + "smoke:out": [[14, 0.18]], + "idle": [ + [ + 0, + 0.6 + ] + ], + "laptop": [ + [ + 15, + 0.2 + ], + [ + 16, + 0.22 + ], + [ + 17, + 0.28 + ], + [ + 18, + 0.24 + ], + [ + 19, + 0.34 + ], + [ + 20, + 0.4 + ], + [ + 21, + 0.3 + ], + [ + 22, + 0.34 + ], + [ + 23, + 0.22 + ], + [ + 24, + 0.42 + ], + [ + 25, + 0.24 + ], + [ + 26, + 0.2 + ] + ], + "laptop:in": [ + [ + 27, + 0.2 + ], + [ + 28, + 0.22 + ], + [ + 29, + 0.22 + ], + [ + 30, + 0.26 + ], + [ + 31, + 0.2 + ], + [ + 32, + 0.2 + ], + [ + 33, + 0.2 + ], + [ + 34, + 0.22 + ], + [ + 35, + 0.2 + ], + [ + 36, + 0.28 + ], + [ + 37, + 0.24 + ], + [ + 38, + 0.28 + ] + ], + "laptop:out": [ + [ + 38, + 0.28 + ], + [ + 37, + 0.24 + ], + [ + 36, + 0.28 + ], + [ + 35, + 0.2 + ], + [ + 34, + 0.22 + ], + [ + 33, + 0.2 + ], + [ + 32, + 0.2 + ], + [ + 31, + 0.2 + ], + [ + 30, + 0.26 + ], + [ + 29, + 0.22 + ], + [ + 28, + 0.22 + ], + [ + 27, + 0.2 + ] + ] + } +} diff --git a/assets/mascot-pipeline/examples/motion-library/set-c/atlas-0.webp b/assets/mascot-pipeline/examples/motion-library/set-c/atlas-0.webp new file mode 100644 index 00000000..238c69ce Binary files /dev/null and b/assets/mascot-pipeline/examples/motion-library/set-c/atlas-0.webp differ diff --git a/assets/mascot-pipeline/examples/motion-library/set-c/motion.json b/assets/mascot-pipeline/examples/motion-library/set-c/motion.json new file mode 100644 index 00000000..046adc72 --- /dev/null +++ b/assets/mascot-pipeline/examples/motion-library/set-c/motion.json @@ -0,0 +1,148 @@ +{ + "cols": 5, + "perSheet": 20, + "cell": [ + 512, + 780 + ], + "clips": { + "idle": [ + [ + 0, + 0.6 + ] + ], + "laptop:in": [ + [ + 1, + 0.24 + ], + [ + 2, + 0.24 + ], + [ + 3, + 0.25 + ], + [ + 4, + 0.24 + ], + [ + 5, + 0.24 + ], + [ + 6, + 0.24 + ], + [ + 7, + 0.24 + ], + [ + 8, + 0.28 + ], + [ + 9, + 0.24 + ], + [ + 10, + 0.24 + ], + [ + 11, + 0.22 + ], + [ + 12, + 0.3 + ] + ], + "laptop": [ + [ + 12, + 0.35 + ], + [ + 13, + 0.35 + ], + [ + 14, + 0.45 + ], + [ + 15, + 0.45 + ], + [ + 12, + 0.35 + ], + [ + 13, + 0.35 + ] + ], + "laptop:out": [ + [ + 11, + 0.22 + ], + [ + 10, + 0.24 + ], + [ + 9, + 0.24 + ], + [ + 8, + 0.28 + ], + [ + 7, + 0.24 + ], + [ + 6, + 0.24 + ], + [ + 5, + 0.24 + ], + [ + 4, + 0.24 + ], + [ + 3, + 0.25 + ], + [ + 2, + 0.24 + ], + [ + 1, + 0.24 + ], + [ + 0, + 0.25 + ] + ] + }, + "sheets": [ + { + "file": "atlas-0.webp", + "rows": 4 + } + ] +} diff --git a/assets/mascot-pipeline/examples/placement-regression/case.json b/assets/mascot-pipeline/examples/placement-regression/case.json new file mode 100644 index 00000000..346178ca --- /dev/null +++ b/assets/mascot-pipeline/examples/placement-regression/case.json @@ -0,0 +1,295 @@ +{ + "provenance": "Approved aligned atlas; unaligned reconstruction from the exact same unchanged source drawings. Not an archived rejected build.", + "accepted_source_build": "210ae17bf35d0195", + "aligned_sha256": "ab5818c4546aeefc69e1dde121687f6ec2022fc5fde57ef59205216f2f03ec71", + "unaligned_sha256": "0ca0c886bb19c53468af4908c10b372ff85a3b200934502eff95c0ee4d8c2b53", + "source_canvas": [ + 1024, + 1536 + ], + "scale": 2, + "floor_y": 1446, + "cols": 5, + "cell": [ + 512, + 780 + ], + "alpha_threshold": 128, + "aligned_max_range_px": 1, + "unaligned_min_range_px": 25, + "regions": [ + { + "index": 0, + "source_support_y": 1457, + "shift_y": -11, + "unaligned_region": [ + 0, + 710, + 512, + 734 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 1, + "source_support_y": 1459, + "shift_y": -13, + "unaligned_region": [ + 0, + 711, + 512, + 735 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 2, + "source_support_y": 1460, + "shift_y": -14, + "unaligned_region": [ + 0, + 712, + 512, + 736 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 3, + "source_support_y": 1457, + "shift_y": -11, + "unaligned_region": [ + 0, + 710, + 512, + 734 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 4, + "source_support_y": 1407, + "shift_y": 39, + "unaligned_region": [ + 0, + 685, + 512, + 709 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 5, + "source_support_y": 1408, + "shift_y": 38, + "unaligned_region": [ + 0, + 686, + 512, + 710 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 6, + "source_support_y": 1453, + "shift_y": -7, + "unaligned_region": [ + 0, + 708, + 512, + 732 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 7, + "source_support_y": 1454, + "shift_y": -8, + "unaligned_region": [ + 0, + 709, + 512, + 733 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 8, + "source_support_y": 1436, + "shift_y": 10, + "unaligned_region": [ + 0, + 700, + 512, + 724 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 9, + "source_support_y": 1439, + "shift_y": 7, + "unaligned_region": [ + 0, + 701, + 512, + 725 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 10, + "source_support_y": 1441, + "shift_y": 5, + "unaligned_region": [ + 0, + 702, + 512, + 726 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 11, + "source_support_y": 1443, + "shift_y": 3, + "unaligned_region": [ + 0, + 703, + 512, + 727 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 12, + "source_support_y": 1467, + "shift_y": -21, + "unaligned_region": [ + 0, + 715, + 512, + 739 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 13, + "source_support_y": 1466, + "shift_y": -20, + "unaligned_region": [ + 0, + 715, + 512, + 739 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 14, + "source_support_y": 1467, + "shift_y": -21, + "unaligned_region": [ + 0, + 715, + 512, + 739 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + }, + { + "index": 15, + "source_support_y": 1466, + "shift_y": -20, + "unaligned_region": [ + 0, + 715, + 512, + 739 + ], + "aligned_region": [ + 0, + 705, + 512, + 729 + ] + } + ], + "limits_note": "Footwear-region diagnostic values for this example only; no alpha is removed. Stable support does not prove body/prop scale, anatomy or facial performance." +} diff --git a/assets/mascot-pipeline/examples/placement-regression/unaligned.webp b/assets/mascot-pipeline/examples/placement-regression/unaligned.webp new file mode 100644 index 00000000..495cbb4d Binary files /dev/null and b/assets/mascot-pipeline/examples/placement-regression/unaligned.webp differ diff --git a/assets/mascot-pipeline/requirements.txt b/assets/mascot-pipeline/requirements.txt new file mode 100644 index 00000000..0d59398e --- /dev/null +++ b/assets/mascot-pipeline/requirements.txt @@ -0,0 +1,2 @@ +numpy +Pillow diff --git a/assets/mascot-pipeline/scripts/build_sprite.py b/assets/mascot-pipeline/scripts/build_sprite.py new file mode 100644 index 00000000..06d65d98 --- /dev/null +++ b/assets/mascot-pipeline/scripts/build_sprite.py @@ -0,0 +1,286 @@ +"""Build sprite sheets and animation data for a CanvasTTY mascot plugin. + +Usage: python build_sprite.py path/to/character.json +Paths in character.json are relative to that file. Run qa_frames.py first. +""" +import json +import hashlib +import math +import os +import re +from pathlib import Path +import sys + +import numpy as np +from PIL import Image, ImageDraw, ImageFilter + + +def load_config(path): + with open(path, encoding='utf-8') as f: + c = json.load(f) + root = Path(path).resolve().parent + for key in ('frames_dir', 'plugin_dir'): + if key not in c: + raise ValueError(f'missing {key} in {path}') + c[key] = str((root / c[key]).resolve()) + c.setdefault('scale', 1) + c.setdefault('cols', 6) + c.setdefault('pad_bottom', 48) + c.setdefault('margin_x', 40) + c.setdefault('shadow', False) + c.setdefault('transition_sec', 0.18) + c.setdefault('airborne', []) + c.setdefault('ground_anchors', {}) + if not c.get('clips'): + raise ValueError('character.json needs at least one clip') + for key in ('scale', 'cols', 'rows_per_sheet'): + value = c.get(key, 5) + if not isinstance(value, int) or isinstance(value, bool) or value < 1: + raise ValueError(f'{key} must be a positive integer') + return c + + +def expand(seq): + """Clip entries are [frame, seconds] or {"seq": [[frame, seconds], ...], "times": n}.""" + out = [] + for item in seq: + if isinstance(item, dict): + times = item.get('times', 1) + if not isinstance(times, int) or isinstance(times, bool) or times < 1: + raise ValueError('sequence times must be a positive integer') + out += expand(item['seq']) * times + else: + name, duration = item[0], float(item[1]) + if not re.fullmatch(r'[a-z0-9][a-z0-9._-]*', name) or not math.isfinite(duration) or duration <= 0: + raise ValueError(f'invalid frame name or duration: {item}') + out.append((name, duration)) + return out + + +def digest(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def frame_path(directory, name): + for suffix in ('-fixed.png', '.png'): + path = Path(directory) / f'{name}{suffix}' + if path.is_file(): + return path + return None + + +def resolve_clips(c): + """QA, build and handoff must refer to the same explicit and legacy clips.""" + clips = {name: expand(seq) for name, seq in c['clips'].items()} + breath = c.get('breath') + if breath: + frames = [(n, float(breath.get('sec', 0.45))) for n in breath['frames']] + half = len(frames) // 2 + blink = [(breath['blink'], 0.12)] if breath.get('blink') else [] + clips['idle'] = expand(frames[:half] + blink + frames[half:]) + elif c.get('idle'): + clips['idle'] = expand(c['idle']) + for action in list(clips): + if action != 'idle' and ':' not in action: + for suffix, prefix in (('in', 'to'), ('out', 'from')): + name = f'{prefix}-{action}' + if frame_path(c['frames_dir'], name): + clips.setdefault(f'{action}:{suffix}', expand([[name, c['transition_sec']]])) + if any(not sequence for sequence in clips.values()): + raise ValueError('every clip must contain at least one frame') + return clips + + +def main(cfg_path): + c = load_config(cfg_path) + frames_dir, plugin_dir = c['frames_dir'], c['plugin_dir'] + if not os.path.isdir(frames_dir): + raise ValueError(f'frames directory does not exist: {frames_dir}') + if os.path.exists(plugin_dir) and os.path.samefile(frames_dir, plugin_dir): + raise ValueError('frames_dir and plugin_dir must differ') + + def path_of(name): + return frame_path(frames_dir, name) + + def read(name): + # Soft effects and green artwork are data, not a guessed background. + return np.asarray(Image.open(path_of(name)).convert('RGBA')).copy() + + # ---- clips ---- + clips = resolve_clips(c) + unknown = set(c.get('deck', [])) - set(clips) + if unknown: + raise ValueError('deck refers to unknown actions: ' + ', '.join(sorted(unknown))) + + names, missing = [], [] + for seq in clips.values(): + for n, _ in seq: + if path_of(n): + if n not in names: + names.append(n) + elif n not in missing: + missing.append(n) + if missing: + raise ValueError('missing frames: ' + ', '.join(missing)) + if not names: + raise ValueError('no frames found in ' + frames_dir) + + # ---- floor line from the base frame ---- + base = c.get('floor_frame') or names[0] + if not path_of(base): + raise ValueError(f'floor_frame is missing: {base}') + base_pixels = read(base) + if not np.any(base_pixels[..., 3]): + raise ValueError(f'floor_frame is empty: {base}') + floor = c.get('floor_y', int(np.where(base_pixels[..., 3] > 0)[0].max())) + if not isinstance(floor, int) or not 0 <= floor < base_pixels.shape[0]: + raise ValueError('floor_y must be a row inside the source canvas') + if c['ground_anchors'] and 'floor_y' not in c: + raise ValueError('explicit ground_anchors require an explicit floor_y') + unknown_anchors = set(c['ground_anchors']) - set(names) + if unknown_anchors: + raise ValueError('ground_anchors refer to unused frames: ' + ', '.join(sorted(unknown_anchors))) + + raw, placements = {}, {} + base_shape = base_pixels.shape + for n in names: + a = read(n) + if a.shape != base_shape: + raise ValueError(f'{n} has size {a.shape[1]}x{a.shape[0]}; expected {base_shape[1]}x{base_shape[0]}') + if not np.any(a[..., 3]): + raise ValueError(f'{n} is empty') + if np.all(a[..., 3] > 0): + raise ValueError(f'{n} has no fully transparent background pixels') + shift = 0 + if n in c['ground_anchors'] and n not in c['airborne']: + anchor = c['ground_anchors'][n] + if not isinstance(anchor, int) or not 0 <= anchor < len(a): + raise ValueError(f'invalid ground anchor for {n}') + shift = floor - anchor + visible_y = np.where(a[..., 3] > 0)[0] + if visible_y.min() + shift < 0 or visible_y.max() + shift >= len(a): + raise ValueError(f'ground translation clips visible art: {n}') + moved = np.zeros_like(a) + if shift >= 0: + moved[shift:] = a[:len(a) - shift] + else: + moved[:shift] = a[-shift:] + a = moved + placements[n] = {'source': path_of(n).name, 'anchor': c['ground_anchors'].get(n), + 'shift_y': shift, 'airborne': n in c['airborne']} + raw[n] = np.vstack([a, np.zeros((c['pad_bottom'], a.shape[1], 4), np.uint8)]) + + xs = np.concatenate([np.where(f[..., 3].any(0))[0] for f in raw.values()]) + width = next(iter(raw.values())).shape[1] + x0 = max(0, xs.min() - c['margin_x']); x1 = min(width, xs.max() + 1 + c['margin_x']) + s = c['scale'] + + def with_shadow(a): + img = Image.fromarray(a) + if not c['shadow']: + return img + ys, cols = np.where(a[..., 3] > 0) + lift = max(0, floor - ys.max()) # how high the feet are above the floor + feet = cols[ys > ys.max() - 60] + cx = (feet.min() + feet.max()) / 2 + w = (feet.max() - feet.min()) * 1.15 * max(0.45, 1 - lift / 500) + alpha = int(110 * max(0.3, 1 - lift / 400)) + layer = Image.new('RGBA', img.size, (0, 0, 0, 0)) + ImageDraw.Draw(layer).ellipse([cx - w / 2, floor - w * 0.07, cx + w / 2, floor + w * 0.07], fill=(0, 0, 0, alpha)) + layer = layer.filter(ImageFilter.GaussianBlur(8)) + layer.alpha_composite(img) + return layer + + cells = {} + for n, a in raw.items(): + img = with_shadow(a[:, x0:x1]) + w, h = img.size + cells[n] = img if s == 1 else img.convert('RGBa').resize( + (max(1, w // s), max(1, h // s)), Image.LANCZOS + ).convert('RGBA') + + cw, ch = next(iter(cells.values())).size + # Bound decoded surfaces as well as compressed file sizes. + max_side = 4096 + if max(cw, ch) > max_side: + raise ValueError('a sprite cell exceeds 4096 px; reduce padding or increase scale') + cols = min(c['cols'], len(names), max_side // cw) + rows_per_sheet = min(c.get('rows_per_sheet', 5), max_side // ch) + per_sheet = cols * rows_per_sheet + os.makedirs(plugin_dir, exist_ok=True) + for old in os.listdir(plugin_dir): + if old.startswith('sprite') and old.endswith('.webp'): + os.remove(os.path.join(plugin_dir, old)) + sheets = [] + for start in range(0, len(names), per_sheet): + chunk = names[start:start + per_sheet] + rows = (len(chunk) + cols - 1) // cols + sheet = Image.new('RGBA', (cw * cols, ch * rows), (0, 0, 0, 0)) + for i, n in enumerate(chunk): + sheet.paste(cells[n], ((i % cols) * cw, (i // cols) * ch)) + file = f'sprite-{len(sheets)}.webp' + sheet.save(os.path.join(plugin_dir, file), lossless=True, method=6) + if os.path.getsize(os.path.join(plugin_dir, file)) > 8_000_000: + raise ValueError(f'{file} exceeds CanvasTTY\'s 8 MB asset limit; lower rows_per_sheet or increase scale') + sheets.append({'file': file, 'rows': rows}) + + index = {n: i for i, n in enumerate(names)} + table = { + 'cols': cols, 'perSheet': per_sheet, 'sheets': sheets, 'cell': [cw, ch], + 'clips': {k: [[index[n], d] for n, d in v if n in index] for k, v in clips.items()}, + 'deck': c.get('deck', [k for k in c['clips'] if k != 'idle']), + 'labels': c.get('labels', {}), 'loops': c.get('loops', {}), 'follow': c.get('follow', {}), + 'frameNames': names, + } + sources = {n: {'source': path_of(n).name, 'sha256': digest(path_of(n))} + for n in dict.fromkeys([*names, base])} + config_hash = digest(cfg_path) + table['clips'] = {k: v for k, v in table['clips'].items() if v} + manifest_path = Path(plugin_dir) / 'canvastty.plugin.json' + if manifest_path.exists(): + manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + manifest['name'] = c['name'] + manifest['description'] = c.get('description', f"Animated {c['name']} mascot for CanvasTTY") + for contribution in manifest.get('contributions', []): + if contribution.get('kind') == 'canvas-app': + contribution['title'] = c['name'] + contribution['description'] = manifest['description'] + height = contribution['defaultSize']['height'] + contribution['defaultSize']['width'] = max(128, min(1600, round(height * cw / ch))) + contribution['minSize']['width'] = 128 + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + assets = {p.relative_to(plugin_dir).as_posix(): digest(p) + for p in sorted(Path(plugin_dir).rglob('*')) if p.is_file() and p.name != 'frames.js'} + table['buildId'] = hashlib.sha256((config_hash + json.dumps(sources, sort_keys=True) + + json.dumps(assets, sort_keys=True)).encode()).hexdigest()[:16] + with open(os.path.join(plugin_dir, 'frames.js'), 'w', encoding='utf-8') as f: + f.write('window.SPRITE = ' + json.dumps(table, ensure_ascii=False) + ';\n') + package_size = sum(path.stat().st_size for path in Path(plugin_dir).rglob('*') if path.is_file()) + if package_size > 25_000_000: + raise ValueError('plugin exceeds CanvasTTY\'s 25 MB package limit; reduce frames or image size') + + report = { + 'build_id': table['buildId'], 'config_sha256': config_hash, 'frames': sources, + 'placement': {'source_canvas': [base_shape[1], base_shape[0]], 'crop_x': int(x0), + 'crop_width': int(x1 - x0), 'padded_height': base_shape[0] + c['pad_bottom'], + 'scale': s, 'cell': [cw, ch], + 'floor_y': floor, 'frames': placements}, + 'files': {p.relative_to(plugin_dir).as_posix(): digest(p) + for p in sorted(Path(plugin_dir).rglob('*')) if p.is_file()}, + 'clips': {k: {'unique_drawings': len({n for n, _ in v}), 'playback_steps': len(v), + 'seconds': round(sum(d for _, d in v), 6)} for k, v in clips.items()}, + } + (Path(cfg_path).resolve().parent / 'build-report.json').write_text( + json.dumps(report, indent=2, ensure_ascii=False) + '\n', encoding='utf-8' + ) + + size_mb = sum(os.path.getsize(os.path.join(plugin_dir, s['file'])) for s in sheets) / 1e6 + print(f'frames {len(names)} cell {cw}x{ch} sheets {[(s["file"], s["rows"]) for s in sheets]} {size_mb:.1f} MB floor y={floor}') + print('clips:', {k: len(v) for k, v in table['clips'].items() if ':' not in k}) + print('transitions:', sorted({k.split(':')[0] for k in table['clips'] if ':' in k}) or 'none') + + +if __name__ == '__main__': + if len(sys.argv) != 2: + sys.exit(__doc__) + main(sys.argv[1]) diff --git a/assets/mascot-pipeline/scripts/finalize_mascot.py b/assets/mascot-pipeline/scripts/finalize_mascot.py new file mode 100644 index 00000000..8ee42234 --- /dev/null +++ b/assets/mascot-pipeline/scripts/finalize_mascot.py @@ -0,0 +1,231 @@ +"""Validate a built mascot plugin and write the CanvasTTY handoff result. + +Usage: python scripts/finalize_mascot.py path/to/character.json [--install-approved] +Default: record reviewed only. --install-approved requests host installation. +--verify-only validates the same evidence without rewriting the result. +""" + +import argparse +import json +import hashlib +from pathlib import Path + +from qa_frames import frame_path, referenced_names +from build_sprite import load_config, resolve_clips + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def verify_build(config_path): + """Check the current sources and package before preview or handoff.""" + config = load_config(config_path) + project = Path(config_path).resolve().parent + report_path = project / 'build-report.json' + if not report_path.is_file(): + raise ValueError('build report is missing; rebuild the plugin') + report = json.loads(report_path.read_text(encoding='utf-8')) + if report.get('config_sha256') != digest(Path(config_path)): + raise ValueError('build is stale: configuration changed') + records = report.get('frames', {}) + if set(records) != set(referenced_names(config)): + raise ValueError('build does not cover current clips') + for name, record in records.items(): + source = frame_path(config['frames_dir'], name) + if source is None or source.name != record.get('source') or digest(source) != record.get('sha256'): + raise ValueError(f'build is stale: source changed: {name}') + plugin = Path(config['plugin_dir']) + actual = {p.relative_to(plugin).as_posix(): digest(p) for p in plugin.rglob('*') if p.is_file()} + if not actual or actual != report.get('files'): + raise ValueError('build is stale: plugin files changed') + return report + + +def verify_review(config_path, build, draft=False): + config = load_config(config_path) + review_path = config_path.parent / ('draft-review.json' if draft else 'review.json') + if not review_path.is_file(): + raise ValueError(f'{review_path.name} is missing; review the current build with the user') + review = json.loads(review_path.read_text(encoding='utf-8')) + if review.get('build_id') != build['build_id'] or review.get('draft_approved' if draft else 'user_approved') is not True: + raise ValueError('current build has not been approved by the user') + if draft and review.get('user_approved') is not False: + raise ValueError('draft acceptance must not claim final visual approval') + plan_path = config_path.parent / 'character-full-plan.json' + deferred = [] + if draft or plan_path.is_file(): + if not plan_path.is_file() or review.get('full_plan_sha256') != digest(plan_path): + raise ValueError('preserved full plan is missing or its review hash is stale') + plan = json.loads(plan_path.read_text(encoding='utf-8')) + for action in plan.get('deck', []): + phases = plan.get('required_phases', {}).get(action) + if not isinstance(phases, list) or not phases: + raise ValueError(f'full plan has no required phases: {action}') + actual = {p['id'] for p in config.get('required_phases', {}).get(action, [])} if action in config['deck'] else set() + deferred.extend(f'{action}/{p["id"]}' for p in phases if p['id'] not in actual) + if not plan.get('deck'): + raise ValueError('full plan has no requested actions') + if not draft and deferred: + raise ValueError('final delivery is missing full-plan phases: ' + ', '.join(deferred)) + if draft and review.get('deferred_phases') != deferred: + raise ValueError('draft must list every deferred full-plan phase in plan order') + unresolved = review.get('unresolved') + if not isinstance(unresolved, list) or any(not isinstance(item, str) or not item.strip() for item in unresolved): + raise ValueError('unresolved must explicitly list known concerns') + if draft: + limits = review.get('limitations') + if not isinstance(limits, list) or not limits or any(not isinstance(item, str) or not item.strip() for item in limits): + raise ValueError('draft needs explicit user-accepted limitations') + if not set(unresolved + deferred) <= set(limits): + raise ValueError('draft limitations must retain all unresolved concerns and deferred phases') + for check in ('source', 'scale', 'neighbors', 'materials_contact', 'effects', 'main_loop', 'boundaries', 'browser'): + note = review.get('checks', {}).get(check) + if not isinstance(note, str) or not note.strip(): + raise ValueError(f'missing observed review: {check}') + observation = review.get('observations', {}).get(check, {}) + if not isinstance(observation.get('evidence'), str) or not observation['evidence'].strip(): + raise ValueError(f'missing observation provenance: {check}') + mode = observation.get('mode') + if mode == 'observed_current_build': + if observation.get('build_id') != build['build_id']: + raise ValueError(f'observation belongs to another build: {check}') + elif mode == 'reused_unchanged_pixels' and check in ('source', 'materials_contact', 'effects'): + hashes = observation.get('source_sha256') + if (not isinstance(hashes, dict) or not hashes or not observation.get('previous_build_id') + or any(build['frames'].get(name, {}).get('sha256') != value for name, value in hashes.items())): + raise ValueError(f'reused pixel evidence is stale: {check}') + elif mode == 'unverified' and draft and check in unresolved: + continue + else: + raise ValueError(f'fresh current-build observation is required: {check}') + clips = resolve_clips(config) + requirements = config.get('required_phases', {}) + for action in config['deck']: + phases = requirements.get(action) + if not isinstance(phases, list) or not phases: + raise ValueError(f'required action phases are missing: {action}') + seen = set() + for phase in phases: + name = phase.get('id') + clip = phase.get('clip', action) + frames = phase.get('frames') + if not isinstance(name, str) or not name.strip() or name in seen: + raise ValueError(f'invalid or duplicate phase: {action}') + seen.add(name) + if clip not in (action, f'{action}:in', f'{action}:out') or not frames: + raise ValueError(f'phase has no valid clip/frames: {action}/{name}') + if not set(frames) <= {f for f, _ in clips.get(clip, [])}: + raise ValueError(f'phase frames are absent from its clip: {action}/{name}') + note = review.get('phases', {}).get(action, {}).get(name) + if not isinstance(note, str) or not note.strip(): + raise ValueError(f'phase was not visually reviewed: {action}/{name}') + if not draft and unresolved != []: + raise ValueError('review must explicitly report no unresolved concerns before approval') + for clip, sequence in clips.items(): + coverage = review.get('coverage', {}).get(clip, {}) + steps = coverage.get('steps') + if (not isinstance(steps, list) or any(type(step) is not int for step in steps) + or sorted(steps) != list(range(len(sequence))) + or not isinstance(coverage.get('note'), str) or not coverage['note'].strip()): + raise ValueError(f'every built step must be inspected and recorded: {clip}') + for action in config['deck']: + playback = review.get('playback', {}).get(action, {}) + if draft and 'main_loop' in unresolved and playback.get('mode') == 'unverified': + if not isinstance(playback.get('note'), str) or not playback['note'].strip(): + raise ValueError(f'unverified draft playback needs an explicit limitation: {action}') + continue + if (type(playback.get('main_loops')) is not int or playback['main_loops'] < 2 + or not isinstance(playback.get('note'), str) or not playback['note'].strip() + or any(playback.get(phase) is not True for phase in ('entrance', 'exit') + if f'{action}:{"in" if phase == "entrance" else "out"}' in clips)): + raise ValueError(f'full entrance/exit and two live main loops must be observed: {action}') + manifest_path = config_path.parent / 'continuity.json' + continuity_path = config_path.parent / 'continuity-report.json' + if not manifest_path.is_file() or not continuity_path.is_file(): + raise ValueError('source-to-atlas continuity evidence is missing') + continuity = json.loads(continuity_path.read_text(encoding='utf-8')) + manifest_hash = digest(manifest_path) + if (continuity.get('build_id') != build['build_id'] + or continuity.get('manifest_sha256') != manifest_hash + or review.get('continuity_manifest_sha256') != manifest_hash + or review.get('continuity_report_sha256') != digest(continuity_path) + or set(continuity.get('frames', {})) != {name for seq in clips.values() for name, _ in seq}): + raise ValueError('continuity evidence/review is stale or incomplete') + for flag in continuity.get('flags', []): + note = review.get('continuity_decisions', {}).get(flag['id']) + if draft and flag['id'] in unresolved: + continue + if not isinstance(note, str) or not note.strip(): + raise ValueError(f'continuity flag needs an observed explanation or repair: {flag["id"]}') + return review + + +def main(config_file, install_approved=False, verify_only=False, draft=False): + config_path = Path(config_file).expanduser().resolve(strict=True) + config = json.loads(config_path.read_text(encoding="utf-8")) + plugin = (config_path.parent / config["plugin_dir"]).resolve() + required = ("canvastty.plugin.json", "index.html", "player.js", "frames.js") + missing = [name for name in required if not (plugin / name).is_file()] + sprites = sorted(plugin.glob("sprite-*.webp")) if plugin.is_dir() else [] + if missing or not sprites: + raise ValueError(f"incomplete plugin: missing {missing or ['sprite-*.webp']}") + manifest = json.loads((plugin / "canvastty.plugin.json").read_text(encoding="utf-8")) + if manifest.get("id") != config["id"]: + raise ValueError("plugin ID differs from character.json") + actions = [name for name in config.get("deck", []) if name in config.get("clips", {})] + if not actions: + raise ValueError("no completed selectable actions are configured") + frames = (config_path.parent / config["frames_dir"]).resolve() + report_path = frames / "qa-report.json" + if not report_path.is_file(): + raise ValueError("frame QA is missing") + report = json.loads(report_path.read_text(encoding="utf-8")) + if report.get("config_sha256") != digest(config_path) or report.get("missing"): + raise ValueError("frame QA is stale; rerun it and review the frames") + if not draft and report.get("visual_review", {}).get("status") != "approved": + raise ValueError("full-size visual review is pending") + records = report.get("frames") or [] + if not records or any(record.get("issues") for record in records): + raise ValueError("frame QA has unresolved issues") + if {record.get("name") for record in records} != set(referenced_names(load_config(config_path))): + raise ValueError("frame QA does not cover the current clips") + for record in records: + source = frame_path(frames, record["name"]) + if source is None or source.name != record.get("source") or digest(source) != record.get("sha256"): + raise ValueError(f"frame changed after review: {record['name']}") + build = verify_build(config_path) + review = verify_review(config_path, build, draft) + if install_approved and review.get('installation_approved') is not True: + raise ValueError('installation needs the user\'s explicit approval for this build') + if verify_only: + print(f'Verified build: {build["build_id"]}') + return + result = { + "schemaVersion": 1, + "status": "ready_for_host" if install_approved else "reviewed", + "buildId": build['build_id'], + "installationApproved": install_approved, + "delivery": "draft" if draft else "final", + "limitations": review['limitations'] if draft else [], + "id": config["id"], + "name": config["name"], + "pluginDirectory": str(plugin), + "actions": list(dict.fromkeys(actions)), + "presentation": {"transparentCard": True, "resizable": True}, + } + target = config_path.parent / "mascot-result.json" + temporary = target.with_suffix(".json.tmp") + temporary.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + temporary.replace(target) + print(f"handoff result: {target}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('config') + parser.add_argument('--install-approved', action='store_true') + parser.add_argument('--verify-only', action='store_true') + parser.add_argument('--draft', action='store_true', help='Accept an explicitly reviewed draft without claiming final approval') + args = parser.parse_args() + main(args.config, args.install_approved, args.verify_only, args.draft) diff --git a/assets/mascot-pipeline/scripts/prepare_input.py b/assets/mascot-pipeline/scripts/prepare_input.py new file mode 100644 index 00000000..cf96c37d --- /dev/null +++ b/assets/mascot-pipeline/scripts/prepare_input.py @@ -0,0 +1,56 @@ +"""Validate an input PNG/JPEG and create a fresh mascot project workspace. + +Usage: python scripts/prepare_input.py --image IMAGE.png|IMAGE.jpg --project PROJECT_DIR +The destination must not contain files; existing work is never overwritten. +""" + +import argparse +import shutil +from pathlib import Path + +from PIL import Image, ImageOps + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--image", type=Path, required=True) + parser.add_argument("--project", type=Path, required=True) + args = parser.parse_args() + + source = args.image.expanduser().resolve(strict=True) + project = args.project.expanduser().resolve() + if project.exists() and any(project.iterdir()): + raise ValueError(f"project directory is not empty: {project}") + if source.stat().st_size > 20 * 1024 * 1024: + raise ValueError("choose a PNG or JPEG up to 20 MB") + with Image.open(source) as image: + image_format = image.format + if image_format not in ("PNG", "JPEG"): + raise ValueError("the reference must be a PNG or JPEG file") + prepared = ImageOps.exif_transpose(image).convert("RGBA") if image_format == "JPEG" else image.convert("RGBA") + alpha = prepared.getchannel("A") + minimum, maximum = alpha.getextrema() + if maximum == 0: + raise ValueError("the image has no visible character pixels") + dimensions = prepared.size + if not all(32 <= dimension <= 4096 for dimension in dimensions): + raise ValueError("image dimensions must be between 32 and 4096 pixels") + + references = project / "references" + frames = project / "frames" + references.mkdir(parents=True, exist_ok=True) + frames.mkdir(parents=True, exist_ok=True) + target = references / "character.png" + original = references / ("character.jpg" if image_format == "JPEG" else "character.png") + shutil.copy2(source, original) + if image_format == "JPEG": + prepared.save(target, format="PNG") + print(f"original: {original}") + print(f"reference: {target}") + print(f"dimensions: {dimensions[0]}x{dimensions[1]}") + print(f"background removal required: {minimum == 255}") + print(f"frames: {frames}") + + +if __name__ == "__main__": + main() diff --git a/assets/mascot-pipeline/scripts/preview_animation.py b/assets/mascot-pipeline/scripts/preview_animation.py new file mode 100644 index 00000000..4d30b687 --- /dev/null +++ b/assets/mascot-pipeline/scripts/preview_animation.py @@ -0,0 +1,89 @@ +"""Create a contact sheet and timed demonstration from the current built atlases. + +Usage: python preview_animation.py path/to/character.json --action wave +The demonstration includes idle, entry, repeated main cycles and exit. Looping the +demonstration repeats its entrance; the live player's selected mode does not. +""" + +import argparse +import json +import textwrap +from pathlib import Path + +from PIL import Image, ImageDraw + +from build_sprite import load_config +from finalize_mascot import verify_build + + +def main(config_path, action, cycles=2, height=520): + config_path = Path(config_path).resolve() + config = load_config(config_path) + report = verify_build(config_path) + plugin = Path(config['plugin_dir']) + text = (plugin / 'frames.js').read_text(encoding='utf-8').strip() + sprite = json.loads(text.removeprefix('window.SPRITE = ').removesuffix(';')) + if action not in sprite['deck'] or action not in sprite['clips']: + raise ValueError('action must be an existing selectable action') + if cycles < 1 or height < 32: + raise ValueError('cycles must be positive and preview height at least 32') + clips = sprite['clips'] + phases = [('idle', clips.get('idle', [])), ('entry', clips.get(f'{action}:in', []))] + phases.extend((f'loop {i + 1}', clips[action]) for i in range(cycles)) + phases.extend([('exit', clips.get(f'{action}:out', [])), ('idle', clips.get('idle', []))]) + cw, ch = sprite['cell'] + display_size = (max(1, round(cw * height / ch)), height) + atlases, frames, thumbnails, durations = {}, [], [], [] + for phase, sequence in phases: + for index, seconds in sequence: + sheet, local = divmod(index, sprite['perSheet']) + if sheet not in atlases: + atlases[sheet] = Image.open(plugin / sprite['sheets'][sheet]['file']).convert('RGBA') + x, y = local % sprite['cols'] * cw, local // sprite['cols'] * ch + frame = atlases[sheet].crop((x, y, x + cw, y + ch)) + frame = frame.convert('RGBa').resize(display_size, Image.Resampling.LANCZOS).convert('RGBA') + frames.append(frame) + durations.append(max(1, round(seconds * 1000))) + if not phase.startswith('loop ') or phase == 'loop 1': + tile = Image.new('RGBA', frame.size, (60, 55, 72, 255)) + tile.alpha_composite(frame) + tile = tile.convert('RGB') + tile.thumbnail((160, 220)) + labeled = Image.new('RGB', (tile.width, tile.height + 66), (30, 28, 36)) + labeled.paste(tile, (0, 66)) + name = sprite.get('frameNames', [str(i) for i in range(len(report['frames']))])[index] + label = f'{phase} / cell {index} / {seconds:.2f}s\n' + '\n'.join(textwrap.wrap(name, 23)) + ImageDraw.Draw(labeled).text((3, 3), label, fill='white') + thumbnails.append(labeled) + destination = config_path.parent / 'previews' + destination.mkdir(exist_ok=True) + animated = destination / f'{action}.webp' + frames[0].save(animated, save_all=True, append_images=frames[1:], + duration=durations, loop=0, lossless=True, method=4) + columns = min(6, len(thumbnails)) + cell_w = max(tile.width for tile in thumbnails) + cell_h = max(tile.height for tile in thumbnails) + sheet = Image.new('RGB', (columns * cell_w, 36 + ((len(thumbnails) + columns - 1) // columns) * cell_h), (30, 28, 36)) + ImageDraw.Draw(sheet).text((3, 3), f'Build {report["build_id"]} / {action}\nOrdered atlas cells at one shared scale', fill='white') + for index, tile in enumerate(thumbnails): + sheet.paste(tile, (index % columns * cell_w, 36 + index // columns * cell_h)) + contact = destination / f'{action}-sheet.png' + sheet.save(contact) + print(f'Build {report["build_id"]}; action {action}; {len(frames)} demo steps; {sum(durations) / 1000:.3f}s') + scene = [step for key in (f'{action}:in', action, f'{action}:out') for step in clips.get(key, [])] + print(f'Whole-scene unique drawings: {len({step[0] for step in scene})}') + for key in (f'{action}:in', action, f'{action}:out'): + print(f'{key}: {report["clips"].get(key, {"unique_drawings": 0, "playback_steps": 0, "seconds": 0})}') + print(f'Animated preview: {animated}') + print(f'Contact sheet: {contact}') + print('This finite demo repeats entry and exit when its file loops. Verify live menu switching separately.') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('config') + parser.add_argument('--action', required=True) + parser.add_argument('--cycles', type=int, default=2) + parser.add_argument('--height', type=int, default=520) + args = parser.parse_args() + main(args.config, args.action, args.cycles, args.height) diff --git a/assets/mascot-pipeline/scripts/qa_frames.py b/assets/mascot-pipeline/scripts/qa_frames.py new file mode 100644 index 00000000..20834b35 --- /dev/null +++ b/assets/mascot-pipeline/scripts/qa_frames.py @@ -0,0 +1,165 @@ +"""Validate referenced mascot frames and create a contact sheet for visual review. + +Usage: python qa_frames.py path/to/character.json + python qa_frames.py path/to/character.json --approve "review notes" +Exit code 1 means a required frame is missing or a mechanical check failed. +Approval requires a separate full-size visual review; metrics cannot judge art quality. +""" + +import argparse +import hashlib +import json +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw + +from build_sprite import frame_path, load_config, resolve_clips + + +def digest(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def load(path): + pixels = np.asarray(Image.open(path).convert('RGBA')).copy() + background = 'alpha' if np.any(pixels[..., 3] == 0) else 'OPAQUE' + return pixels, background + + +def referenced_names(config): + names = [] + for sequence in resolve_clips(config).values(): + names.extend(name for name, _ in sequence) + names.append(config.get('floor_frame') or names[0]) + return list(dict.fromkeys(names)) + + +def main(config_path): + config_path = Path(config_path).resolve() + config = load_config(config_path) + directory = Path(config['frames_dir']) + names = referenced_names(config) + missing = [name for name in names if frame_path(directory, name) is None] + if missing: + if directory.is_dir(): + (directory / 'qa-report.json').write_text( + json.dumps({'missing': missing, 'frames': [], 'visual_review_required': True}, indent=2) + '\n', + encoding='utf-8', + ) + raise ValueError('missing frames: ' + ', '.join(missing)) + + floor_name = config.get('floor_frame') or names[0] + reference, _ = load(frame_path(directory, floor_name)) + reference_y = np.where(reference[..., 3] > 0)[0] + if not reference_y.size: + raise ValueError(f'floor_frame is empty: {floor_name}') + floor = int(reference_y.max()) + reference_ys, reference_xs = np.where(reference[..., 3] > 0) + reference_width = int(reference_xs.max() - reference_xs.min() + 1) + reference_height = int(reference_ys.max() - reference_ys.min() + 1) + airborne = set(config.get('airborne', [])) + thumbnails = [] + failures = [] + records = [] + + for name in names: + source = frame_path(directory, name) + pixels, background = load(source) + issues = [] + visual_flags = [] + bounds = None + size_ratio = None + if pixels.shape != reference.shape: + issues.append(f'SIZE {pixels.shape[1]}x{pixels.shape[0]}') + if background == 'OPAQUE': + issues.append('BG') + alpha = pixels[..., 3] + ys, xs = np.where(alpha > 0) + if not ys.size: + issues.append('EMPTY') + else: + height, width = alpha.shape + bounds = [int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1] + size_ratio = { + 'width': round((bounds[2] - bounds[0]) / reference_width, 3), + 'height': round((bounds[3] - bounds[1]) / reference_height, 3), + } + if abs(size_ratio['width'] - 1) > 0.01 or abs(size_ratio['height'] - 1) > 0.01: + visual_flags.append('Compare figure scale with the base at full resolution; poses and props can change bounds.') + if xs.min() <= 1 or xs.max() >= width - 2 or ys.min() <= 1 or ys.max() >= height - 2: + issues.append('EDGE') + offset = floor - int(ys.max()) + if name not in airborne and abs(offset) > 30: + visual_flags.append(f'Visible bottom differs by {offset:+d}px; inspect body supports separately from effects/props. No automatic alignment is applied.') + if issues: + failures.append(f'{name}: {", ".join(issues)}') + records.append({ + 'name': name, 'source': source.name, 'sha256': digest(source), + 'bounds': bounds, 'size_ratio_to_base': size_ratio, + 'visual_flags': visual_flags, 'issues': issues, + }) + print(f'{name}: {background}; {", ".join(issues) if issues else "OK"}') + + image = Image.fromarray(pixels) + backing = Image.new('RGBA', image.size, (60, 55, 72, 255)) + backing.alpha_composite(image) + thumb = backing.convert('RGB').resize((max(1, image.width // 6), max(1, image.height // 6))) + ImageDraw.Draw(thumb).text((4, 4), name, fill=(255, 255, 0)) + thumbnails.append(thumb) + + columns = min(8, len(thumbnails)) + cell_w = max(image.width for image in thumbnails) + cell_h = max(image.height for image in thumbnails) + rows = (len(thumbnails) + columns - 1) // columns + sheet = Image.new('RGB', (columns * cell_w, rows * cell_h), (30, 28, 36)) + for index, image in enumerate(thumbnails): + sheet.paste(image, ((index % columns) * cell_w, (index // columns) * cell_h)) + sheet_path = directory / 'qa-sheet.png' + sheet.save(sheet_path) + report_path = directory / 'qa-report.json' + report_path.write_text( + json.dumps({ + 'missing': [], 'frames': records, 'config_sha256': digest(config_path), + 'visual_review_required': True, 'visual_review': {'status': 'pending'}, + }, indent=2) + '\n', + encoding='utf-8', + ) + print(f'Visual review: {sheet_path}') + print(f'Machine-readable report: {report_path}') + if failures: + raise ValueError('frame checks failed:\n' + '\n'.join(failures)) + print(f'Mechanical checks passed for {len(names)} frames. Inspect all frames at full size, then run --approve.') + + +def approve(config_path, notes): + if len(notes.strip()) < 20: + raise ValueError('record specific full-size visual review notes') + config_path = Path(config_path).resolve() + config = load_config(config_path) + report_path = Path(config['frames_dir']) / 'qa-report.json' + report = json.loads(report_path.read_text(encoding='utf-8')) + if report.get('config_sha256') != digest(config_path) or report.get('missing'): + raise ValueError('configuration changed or required frames are missing; rerun QA') + records = report.get('frames') or [] + if not records or any(record.get('issues') for record in records): + raise ValueError('mechanical QA has not passed') + directory = Path(config['frames_dir']) + for record in records: + source = frame_path(directory, record['name']) + if source is None or source.name != record['source'] or digest(source) != record['sha256']: + raise ValueError(f"frame changed after QA: {record['name']}") + report['visual_review'] = {'status': 'approved', 'notes': notes} + report_path.write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print(f'Visual review recorded: {report_path}') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('config') + parser.add_argument('--approve', metavar='NOTES', help='record a completed full-size visual review') + args = parser.parse_args() + if args.approve is not None: + approve(args.config, args.approve) + else: + main(args.config) diff --git a/assets/mascot-pipeline/scripts/reference_frames.py b/assets/mascot-pipeline/scripts/reference_frames.py new file mode 100644 index 00000000..ef4986cf --- /dev/null +++ b/assets/mascot-pipeline/scripts/reference_frames.py @@ -0,0 +1,60 @@ +"""Extract original-sized motion examples from the bundled reference atlases. + +Usage: python reference_frames.py --set set-a --action smoke --output PROJECT/motion-reference +The output is motion evidence, never the new mascot's identity reference. +""" + +import argparse +import json +from pathlib import Path + +from PIL import Image, ImageDraw + + +def main(set_name, action, output): + root = Path(__file__).resolve().parent.parent / 'examples' / 'motion-library' + source = root / set_name + if source.resolve().parent != root.resolve(): + raise ValueError('invalid reference set') + data = json.loads((source / 'motion.json').read_text(encoding='utf-8')) + if action not in data['clips']: + raise ValueError(f'unknown action; available: {list(data["clips"])}') + output = Path(output) + if output.exists() and any(output.iterdir()): + raise ValueError('choose an empty output directory to preserve existing references') + output.mkdir(parents=True, exist_ok=True) + cw, ch = data['cell'] + atlases, thumbnails = {}, [] + sequence = data['clips'][action] + for index, seconds in sequence: + sheet, local = divmod(index, data['perSheet']) + if sheet not in atlases: + atlases[sheet] = Image.open(source / data['sheets'][sheet]['file']).convert('RGBA') + x, y = local % data['cols'] * cw, local // data['cols'] * ch + frame = atlases[sheet].crop((x, y, x + cw, y + ch)) + frame.save(output / f'frame-{index:03d}.png') + tile = Image.new('RGBA', frame.size, (65, 62, 72, 255)) + tile.alpha_composite(frame) + tile = tile.convert('RGB') + tile.thumbnail((180, 260)) + ImageDraw.Draw(tile).text((4, 4), f'{index} / {seconds:g}s', fill='white') + thumbnails.append(tile) + cols = min(6, len(thumbnails)) + width, height = max(t.width for t in thumbnails), max(t.height for t in thumbnails) + contact = Image.new('RGB', (cols * width, ((len(thumbnails) + cols - 1) // cols) * height), (30, 28, 36)) + for i, tile in enumerate(thumbnails): + contact.paste(tile, (i % cols * width, i // cols * height)) + contact.save(output / 'sequence.jpg', quality=95) + (output / 'timing.json').write_text(json.dumps(sequence, indent=2) + '\n', encoding='utf-8') + print(f'{set_name}/{action}: {len(set(i for i, _ in sequence))} unique drawings, ' + f'{len(sequence)} steps, {sum(t for _, t in sequence):.3f}s') + print(output / 'sequence.jpg') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--set', required=True) + parser.add_argument('--action', required=True) + parser.add_argument('--output', required=True) + args = parser.parse_args() + main(args.set, args.action, args.output) diff --git a/assets/mascot-pipeline/scripts/review_continuity.py b/assets/mascot-pipeline/scripts/review_continuity.py new file mode 100644 index 00000000..553882d3 --- /dev/null +++ b/assets/mascot-pipeline/scripts/review_continuity.py @@ -0,0 +1,141 @@ +"""Measure declared support regions in source AND atlas; flag neighbor changes. + +Usage: python review_continuity.py PROJECT/character.json +Requires manually inspected continuity.json. Flags require visual decisions; +neither measurements nor absence of flags constitute artistic approval. +""" + +import argparse +import json +import math +from pathlib import Path + +import numpy as np +from PIL import Image + +from build_sprite import load_config, resolve_clips, digest, frame_path +from finalize_mascot import verify_build + + +def support_row(image, region, threshold): + x0, y0, x1, y1 = region + if not (0 <= x0 < x1 <= image.width and 0 <= y0 < y1 <= image.height): + raise ValueError('support region does not fit the inspected image after placement') + alpha = np.asarray(image.getchannel('A'))[y0:y1, x0:x1] + rows = np.where(alpha > threshold)[0] + if not len(rows): + raise ValueError('declared support region contains no alpha above its diagnostic threshold') + return int(rows.max() + y0) + + +def finite(value): + return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value) + + +def measure(config_path): + config_path = Path(config_path).resolve() + config = load_config(config_path) + build = verify_build(config_path) + project = config_path.parent + manifest_path = project / 'continuity.json' + manifest = json.loads(manifest_path.read_text(encoding='utf-8')) + limits = manifest.get('limits', {}) + for key in ('support_px', 'landmark_px', 'size_fraction'): + if not finite(limits.get(key)) or limits[key] < 0: + raise ValueError(f'declare project-specific finite nonnegative {key}') + sprite = json.loads((Path(config['plugin_dir']) / 'frames.js').read_text(encoding='utf-8') + .strip().removeprefix('window.SPRITE = ').removesuffix(';')) + placement = build['placement'] + cw, ch = sprite['cell'] + sx, sy = cw / placement['crop_width'], ch / placement['padded_height'] + atlas_cache, records = {}, {} + for name in sprite['frameNames']: + entry = manifest.get('frames', {}).get(name, {}) + if entry.get('source_sha256') != build['frames'][name]['sha256']: + raise ValueError(f'continuity annotation missing or stale: {name}') + for field in ('anatomy', 'contact', 'face'): + if not isinstance(entry.get(field), str) or not entry[field].strip(): + raise ValueError(f'missing inspected {field}: {name}') + points, sizes = entry.get('landmarks', {}), entry.get('sizes', {}) + if not points or not sizes: + raise ValueError(f'declare independent body/prop landmarks and sizes: {name}') + if any(not isinstance(v, list) or len(v) != 2 or not all(finite(n) for n in v) + for v in points.values()): + raise ValueError(f'invalid source-coordinate landmarks: {name}') + if any(not finite(v) or v <= 0 for v in sizes.values()): + raise ValueError(f'invalid measured sizes: {name}') + transform = placement['frames'][name] + source = Image.open(frame_path(config['frames_dir'], name)).convert('RGBA') + index = sprite['frameNames'].index(name) + sheet, local = divmod(index, sprite['perSheet']) + if sheet not in atlas_cache: + atlas_cache[sheet] = Image.open(Path(config['plugin_dir']) / sprite['sheets'][sheet]['file']).convert('RGBA') + x, y = local % sprite['cols'] * cw, local // sprite['cols'] * ch + cell = atlas_cache[sheet].crop((x, y, x + cw, y + ch)) + supports = {} + declared = entry.get('supports') + if not isinstance(declared, list): + raise ValueError(f'declare supports (empty only for an unsupported pose): {name}') + if not declared and not str(entry.get('support_note', '')).strip(): + raise ValueError(f'explain absent support: {name}') + for item in declared: + key, region, threshold = item.get('name'), item.get('region'), item.get('alpha_threshold') + if (not isinstance(key, str) or not key or key in supports or not isinstance(region, list) + or len(region) != 4 or any(not isinstance(v, int) or isinstance(v, bool) for v in region) + or not (0 <= region[0] < region[2] <= source.width and 0 <= region[1] < region[3] <= source.height) + or not isinstance(threshold, int) or isinstance(threshold, bool) or not 0 <= threshold < 255 + or not isinstance(item.get('grounded'), bool)): + raise ValueError(f'invalid visually defined support region: {name}') + built_region = [max(0, math.floor((region[0] - placement['crop_x']) * sx)), + max(0, math.floor((region[1] + transform['shift_y']) * sy)), + min(cw, math.ceil((region[2] - placement['crop_x']) * sx)), + min(ch, math.ceil((region[3] + transform['shift_y']) * sy))] + supports[key] = {'source_y': support_row(source, region, threshold), + 'built_y': support_row(cell, built_region, threshold), + 'grounded': item['grounded'] and not transform['airborne'], + 'region': region, 'alpha_threshold': threshold} + records[name] = {'source': transform['source'], 'shift_y': transform['shift_y'], 'supports': supports, + 'landmarks': points, + 'built_landmarks': {k: [(v[0] - placement['crop_x']) * sx, + (v[1] + transform['shift_y']) * sy] for k, v in points.items()}, + 'sizes': sizes, 'anatomy': entry['anatomy'], 'contact': entry['contact'], 'face': entry['face']} + clips = resolve_clips(config) + pairs = [] + for clip, seq in clips.items(): + pairs.extend((f'{clip}:{i}->{clip}:{i+1}', a[0], b[0]) for i, (a, b) in enumerate(zip(seq, seq[1:]))) + if ':' not in clip: + pairs.append((f'{clip}:seam', seq[-1][0], seq[0][0])) + for action in config.get('deck', []): + ordered = [(k, clips[k]) for k in ('idle', f'{action}:in', action, f'{action}:out', 'idle') if k in clips] + pairs.extend((f'{a[0]}:end->{b[0]}:start', a[1][-1][0], b[1][0][0]) for a, b in zip(ordered, ordered[1:])) + flags = [] + for boundary, left, right in pairs: + a, b = records[left], records[right] + def flag(kind, key, value, limit): + if value > limit: + flags.append({'id': f'{boundary}/{kind}/{key}', 'previous': left, 'current': right, + 'kind': kind, 'landmark': key, 'change': round(value, 4), 'limit': limit}) + for key in a['supports'].keys() & b['supports'].keys(): + aa, bb = a['supports'][key], b['supports'][key] + if aa['grounded'] and bb['grounded']: + # Report in source-pixel equivalents after inspecting the actual downsampled atlas. + flag('support', key, abs(aa['built_y'] - bb['built_y']) / sy, limits['support_px']) + for key in a['built_landmarks'].keys() & b['built_landmarks'].keys(): + aa, bb = a['built_landmarks'][key], b['built_landmarks'][key] + flag('trajectory', key, math.hypot((aa[0]-bb[0])/sx, (aa[1]-bb[1])/sy), limits['landmark_px']) + for key in a['sizes'].keys() & b['sizes'].keys(): + flag('size', key, abs(b['sizes'][key] / a['sizes'][key] - 1), limits['size_fraction']) + report = {'build_id': build['build_id'], 'manifest_sha256': digest(manifest_path), + 'limits': limits, 'placement': placement, 'frames': records, + 'compared_boundaries': [p[0] for p in pairs], 'flags': flags} + target = project / 'continuity-report.json' + target.write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8') + print(f'Build {build["build_id"]}: {len(records)} drawings, {len(pairs)} neighbor/boundary checks, {len(flags)} flags') + print('Flags need visual decisions; this is not visual approval.') + return report + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('config') + measure(parser.parse_args().config) diff --git a/assets/mascot-pipeline/scripts/scaffold_plugin.py b/assets/mascot-pipeline/scripts/scaffold_plugin.py new file mode 100644 index 00000000..d03d1fbc --- /dev/null +++ b/assets/mascot-pipeline/scripts/scaffold_plugin.py @@ -0,0 +1,50 @@ +"""Create a CanvasTTY mascot plugin from one character.json config. + +Usage: python scaffold_plugin.py path/to/character.json +The destination must be empty; existing plugin files are never overwritten. +""" + +import json +import re +import sys +from pathlib import Path + +TEMPLATE = Path(__file__).resolve().parent.parent / 'templates' / 'plugin' + + +def main(config_path): + config_path = Path(config_path).resolve() + config = json.loads(config_path.read_text(encoding='utf-8')) + mascot_id = config['id'] + if not re.fullmatch(r'[a-z0-9]+(?:-[a-z0-9]+)*', mascot_id) or mascot_id == 'host': + raise ValueError('id must use lowercase letters, digits and single hyphens') + name = config['name'].strip() + if not name: + raise ValueError('name must not be empty') + destination = (config_path.parent / config['plugin_dir']).resolve() + if destination.exists() and any(destination.iterdir()): + raise ValueError(f'plugin directory is not empty: {destination}') + values = { + '__ID__': mascot_id, + '__NAME__': name, + '__DESCRIPTION__': config.get('description', f'Animated {name} mascot for CanvasTTY'), + } + for source in TEMPLATE.rglob('*'): + if not source.is_file(): + continue + target = destination / source.relative_to(TEMPLATE) + target.parent.mkdir(parents=True, exist_ok=True) + content = source.read_text(encoding='utf-8') + for key, value in values.items(): + replacement = json.dumps(value, ensure_ascii=False)[1:-1] if source.suffix == '.json' else value + content = content.replace(key, replacement) + if source.suffix == '.json': + json.loads(content) + target.write_text(content, encoding='utf-8') + print(f'plugin scaffold: {destination}') + + +if __name__ == '__main__': + if len(sys.argv) != 2: + raise SystemExit(__doc__) + main(sys.argv[1]) diff --git a/assets/mascot-pipeline/scripts/serve_preview.py b/assets/mascot-pipeline/scripts/serve_preview.py new file mode 100644 index 00000000..f04097fc --- /dev/null +++ b/assets/mascot-pipeline/scripts/serve_preview.py @@ -0,0 +1,182 @@ +"""Serve a live, read-only mascot workbench on loopback. + +Usage: python serve_preview.py PROJECT --port 0 +Prints its URL as one JSON line. Keep the process alive during creation. +""" + +import argparse +import functools +import hashlib +import json +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import quote, unquote, urlsplit + + +PAGE = b'''Mascot workbench + +

Mascot workbench

Waiting for source images...

+

Drafts stay visible for review. Displayed images are not automatically approved.

+

Uploaded and prepared references

+

Generated frames

+

Built animation

+

Deterministic atlas review

+

Paused inspection uses actual atlas cells. Source placement is shown separately. +Support lines use declared diagnostic regions; they do not prove anatomy or scale.

+ + + +

Build an animation to inspect its named steps.

+

+'''
+
+
+class Handler(SimpleHTTPRequestHandler):
+    def end_headers(self):
+        self.send_header('Cache-Control', 'no-store')
+        super().end_headers()
+
+    def reply(self, data, content_type):
+        self.send_response(200)
+        self.send_header('Content-Type', content_type)
+        self.send_header('Content-Length', str(len(data)))
+        self.end_headers()
+        self.wfile.write(data)
+
+    def do_GET(self):
+        root = Path(self.directory).resolve()
+        url = urlsplit(self.path).path
+        if url == '/':
+            return self.reply(PAGE, 'text/html; charset=utf-8')
+        if url == '/state':
+            images = []
+            for group in ('references', 'frames'):
+                for path in sorted((root / group).glob('*')):
+                    if (path.suffix.lower() in ('.png', '.jpg', '.jpeg') and path.is_file()
+                            and path.resolve().is_relative_to(root)):
+                        images.append(dict(group=group, name=path.name,
+                                           url=f'/{group}/{quote(path.name)}', modified=path.stat().st_mtime_ns))
+            sprite = {}
+            try:
+                sprite = json.loads((root / 'plugin' / 'frames.js').read_text(encoding='utf-8')
+                                    .strip().removeprefix('window.SPRITE = ').removesuffix(';'))
+            except (OSError, ValueError):
+                pass
+            data = dict(project=root.name, images=images, build=sprite.get('buildId'),
+                        actions=[dict(id=a, label=sprite.get('labels', {}).get(a, a)) for a in sprite.get('deck', [])])
+            try:
+                build_report = json.loads((root / 'build-report.json').read_text(encoding='utf-8'))
+                if (build_report.get('build_id') == sprite.get('buildId') and sprite.get('frameNames')
+                        and build_report.get('placement')):
+                    continuity = None
+                    try:
+                        candidate = json.loads((root / 'continuity-report.json').read_text(encoding='utf-8'))
+                        manifest_hash = hashlib.sha256((root / 'continuity.json').read_bytes()).hexdigest()
+                        if (candidate.get('build_id') == sprite['buildId']
+                                and candidate.get('manifest_sha256') == manifest_hash):
+                            continuity = candidate
+                    except (OSError, ValueError):
+                        pass
+                    data['review'] = dict(sprite=sprite, placement=build_report['placement'], continuity=continuity)
+            except (OSError, ValueError):
+                pass
+            return self.reply(json.dumps(data).encode(), 'application/json')
+        relative = Path(unquote(url).lstrip('/'))
+        resolved = (root / relative).resolve()
+        if (not resolved.is_relative_to(root) or not relative.parts
+                or relative.parts[0] not in ('references', 'frames', 'previews', 'plugin')
+                or resolved.suffix.lower() not in ('.png', '.webp', '.jpg', '.jpeg', '.html', '.js', '.css')
+                or not resolved.is_file()):
+            return self.send_error(404)
+        return super().do_GET()
+
+    def do_HEAD(self):
+        self.send_error(405)
+
+    def log_message(self, *args):
+        pass
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('project', type=Path)
+    parser.add_argument('--port', type=int, default=0)
+    args = parser.parse_args()
+    root = args.project.resolve(strict=True)
+    server = ThreadingHTTPServer(('127.0.0.1', args.port), functools.partial(Handler, directory=str(root)))
+    print(json.dumps({'url': f'http://127.0.0.1:{server.server_port}/', 'project': root.name}), flush=True)
+    try:
+        server.serve_forever()
+    except KeyboardInterrupt:
+        pass
+    finally:
+        server.server_close()
diff --git a/assets/mascot-pipeline/templates/launch-prompt.txt b/assets/mascot-pipeline/templates/launch-prompt.txt
new file mode 100644
index 00000000..cc9ed4dc
--- /dev/null
+++ b/assets/mascot-pipeline/templates/launch-prompt.txt
@@ -0,0 +1,14 @@
+Create an animated CanvasTTY mascot from the attached character image in [PROJECT_DIR]. Read [PIPELINE_DIR]/SKILL.md in full; it is the complete single-page workflow. Use the supplied image and this project's paths.
+
+This session was launched by the user through the Mascots creation button in YOLO/bypass mode. Connect to the specialized CanvasTTY browser connector now and activate the workbench at [PREVIEW_URL]. Keep this same browser preview visible and current throughout preparation, frame generation, repair and animation review. Check the specialized connector before concluding that browser access is unavailable.
+
+Your first creative question is whether to keep the uploaded appearance or change it before animation. Both options require the source-preparation prompt on the skill page: a clear full-body 2D character on actual transparency, followed by explicit canonical approval. Preserve the original. Accept source PNGs or JPG/JPEG photos with or without a background. JPEG intake preserves the original and provides an EXIF-oriented PNG working copy; conversion alone does not remove the background or replace mandatory image-generation preparation.
+
+Offer suggested actions from the bundled motion library plus a custom action/scene with props and emotions. For each chosen action, inspect matching successful example frames or combine relevant movement components for a new action. Examples guide motion only; the approved new character defines identity, scale, colors and materials. Use accepted neighbors for continuity.
+
+Plan every user-required phase, show key poses, then fill named gaps. Perform the mandatory body and prop scale gate after every new/repaired frame and before sequence approval. Do not remove requested phases after failed generation. Show and report the actual main loop separately from entrance and exit, with unique drawings, steps and seconds. Keep the browser on the current build.
+
+Distinguish drawing placement from physical size and intended pose motion before repairing a jump. Track actual footwear/hip support, body and every prop in source and final atlas; an intermediate pose alone cannot fix a shifted floor. Maintain the skill's continuity.json evidence and run review_continuity.py after final changes. Use the workbench's deterministic named-step review to inspect every entrance/main/exit step and boundary, including short holds. Define facial beats separately from facial identity and trace all limb/prop contact chains. Record exact step coverage, live playback, current continuity decisions and unresolved concerns in review.json. Never promote mechanical QA or sparse screenshots into visual approval.
+
+Use the supplied scripts and the skill's review.json contract. Mechanical QA is not visual or user approval. Finalization defaults to reviewed without installation. Request the user's separate approval for installing/replacing the identified current build before using --install-approved. Report installed behavior only after actual host confirmation.
+Preserve character-full-plan.json before any partial delivery. Use separate draft-review.json and --draft for an explicitly accepted incomplete build, retaining every deferred phase and unresolved limitation. Record actual observation provenance; never transfer old build approval or manufacture positive flag explanations. Use only the finalizer and the registered host installer, never manual plugin copies, registry edits or handwritten handoffs. After rejection, inspect the error stage and installation-error.log; submit a corrected handoff or use the host's explicit Retry installation without resetting records or restarting. Report requested, installed, registered and observed runtime state separately.
diff --git a/assets/mascot-pipeline/templates/plugin/canvastty.plugin.json b/assets/mascot-pipeline/templates/plugin/canvastty.plugin.json
new file mode 100644
index 00000000..ec1908a0
--- /dev/null
+++ b/assets/mascot-pipeline/templates/plugin/canvastty.plugin.json
@@ -0,0 +1,19 @@
+{
+  "apiVersion": 1,
+  "id": "__ID__",
+  "name": "__NAME__",
+  "version": "1.0.0",
+  "description": "__DESCRIPTION__",
+  "permissions": [],
+  "contributions": [
+    {
+      "id": "character",
+      "kind": "canvas-app",
+      "title": "__NAME__",
+      "description": "__DESCRIPTION__",
+      "entry": "index.html",
+      "defaultSize": { "width": 320, "height": 520 },
+      "minSize": { "width": 128, "height": 140 }
+    }
+  ]
+}
diff --git a/assets/mascot-pipeline/templates/plugin/index.html b/assets/mascot-pipeline/templates/plugin/index.html
new file mode 100644
index 00000000..b57dadd6
--- /dev/null
+++ b/assets/mascot-pipeline/templates/plugin/index.html
@@ -0,0 +1,41 @@
+
+
+
+  
+  
+  
+
+
+  
+  
+ + +
+ + + + diff --git a/assets/mascot-pipeline/templates/plugin/player.js b/assets/mascot-pipeline/templates/plugin/player.js new file mode 100644 index 00000000..4a4a7c32 --- /dev/null +++ b/assets/mascot-pipeline/templates/plugin/player.js @@ -0,0 +1,191 @@ +// CanvasTTY sprite-character player. Data comes from frames.js (window.SPRITE), written by build_sprite.py: +// { cols, perSheet, sheets:[{file, rows}], cell:[w,h], clips:{name:[[frameIndex, seconds], ...]}, +// deck:[...], labels:{...}, loops:{action:times}, follow:{action:[otherAction, probability]} } +// Clips "X:in" / "X:out" (from to-X / from-X frames) are played around action X automatically. +// "all" mode deals every deck action once per round in random order; the gear menu loops a single action. +(() => { + const S = window.SPRITE; + const el = document.querySelector('.sprite'); + if (!S || !el) return; + let dragging = null; + const sendDrag = (phase, event) => parent.postMessage({ + source: 'canvastty-plugin', type: 'mascot-drag', phase, + x: event.screenX, y: event.screenY, pointerId: event.pointerId + }, '*'); + el.addEventListener('pointerdown', (event) => { + if (event.button !== 0) return; + dragging = event.pointerId; + el.setPointerCapture(event.pointerId); + sendDrag('start', event); + }); + el.addEventListener('pointermove', (event) => { + if (dragging === event.pointerId) sendDrag('move', event); + }); + const endDrag = (event) => { + if (dragging !== event.pointerId) return; + dragging = null; + sendDrag('end', event); + }; + el.addEventListener('pointerup', endDrag); + el.addEventListener('pointercancel', endDrag); + el.addEventListener('lostpointercapture', endDrag); + el.style.aspectRatio = `${S.cell[0]} / ${S.cell[1]}`; + // fit inside the window at any size without distortion + el.style.height = `min(100vh, calc(100vw * ${S.cell[1]} / ${S.cell[0]}))`; + // Frames live in several sheets (one huge sheet is not drawn) and are painted on a canvas from images + // decoded up front: swapping a CSS background between sheets showed an empty frame (flicker) while decoding. + // The canvas keeps the cell's own size and gets a 1:1 copy; the browser scales the element once. + // Sizing the bitmap to the window resampled twice (CanvasTTY zoom is invisible here) - blurry. + const [cw, ch] = S.cell; + const canvas = document.createElement('canvas'); + canvas.width = cw; + canvas.height = ch; + canvas.style.cssText = 'width:100%;height:100%;display:block'; + el.appendChild(canvas); + const ctx = canvas.getContext('2d'); + const sheets = S.sheets.map((s) => { const img = new Image(); img.src = s.file; return img; }); + const ready = Promise.all(sheets.map((img) => img.decode().catch(() => {}))); + const show = (i) => { + const s = Math.floor(i / S.perSheet), local = i % S.perSheet, img = sheets[s]; + if (!img.complete || !img.naturalWidth) return; // not decoded yet: keep the previous frame + ctx.clearRect(0, 0, cw, ch); + ctx.drawImage(img, (local % S.cols) * cw, Math.floor(local / S.cols) * ch, cw, ch, 0, 0, cw, ch); + }; + + // Finish the current clip before exiting so a menu click cannot splice incompatible poses. + // The latest selection is read at boundaries; transitions never queue stale selections. + const sleep = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000)); + const has = (name) => (S.clips[name] || []).length > 0; + const chance = (p) => Math.random() < p; + const play = async (name, times = 1) => { + const clip = S.clips[name] || []; + for (let t = 0; t < times; t++) for (const [i, d] of clip) { show(i); await sleep(d); } + }; + // standing -> to-X -> X -> from-X -> standing + const perform = async (name) => { + await play(`${name}:in`); + let cycles = 0; + do { + await play(name); + cycles++; + } while (mode === name || (mode === 'all' && cycles < ((S.loops || {})[name] || 1))); + await play(`${name}:out`); + const follow = (S.follow || {})[name]; + if (mode === 'all' && follow && has(follow[0]) && chance(follow[1])) await perform(follow[0]); + }; + + const shuffle = (list) => { + for (let i = list.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [list[i], list[j]] = [list[j], list[i]]; + } + return list; + }; + const deal = (last) => { + const deck = shuffle((S.deck || []).filter(has)); + for (let i = 1; i < deck.length; i++) { // separate identical neighbours + if (deck[i] === deck[i - 1]) { + const k = deck.findIndex((n, j) => j > i && n !== deck[i]); + if (k > 0) [deck[i], deck[k]] = [deck[k], deck[i]]; + } + } + if (deck[0] === last && deck.length > 1) deck.push(deck.shift()); + return deck; + }; + + // gear menu: "all", "idle" and every deck action that has frames + const previewAction = new URLSearchParams(window.location.search).get('action'); + let mode = (previewAction === 'idle' && has('idle')) || ((S.deck || []).includes(previewAction) && has(previewAction)) + ? previewAction : 'all'; + document.documentElement.dataset.buildId = S.buildId || ''; + const picker = document.getElementById('picker'); + const toggle = document.getElementById('picker-toggle'); + const menu = document.getElementById('picker-menu'); + if (picker && toggle && menu) { + const defaultLabels = { + en: { all: 'All actions', idle: 'Idle' }, + ru: { all: 'Всё подряд', idle: 'Ожидание' } + }; + let locale = navigator.language.toLowerCase().startsWith('ru') ? 'ru' : 'en'; + const names = ['all', ...(has('idle') ? ['idle'] : []), ...new Set((S.deck || []).filter(has))]; + const close = () => { picker.classList.remove('open'); toggle.setAttribute('aria-expanded', 'false'); }; + const options = new Map(); + for (const name of names) { + const option = document.createElement('button'); + option.type = 'button'; + option.className = 'picker-option'; + option.setAttribute('role', 'menuitemradio'); + option.setAttribute('aria-checked', String(name === mode)); + option.addEventListener('click', () => { + mode = name; + for (const [key, item] of options) item.setAttribute('aria-checked', String(key === name)); + close(); + toggle.focus(); + }); + options.set(name, option); + menu.appendChild(option); + } + const renderLabels = () => { + const labels = Object.assign({}, defaultLabels[locale], S.labels || {}); + for (const [name, option] of options) option.textContent = labels[name] || name; + document.documentElement.lang = locale; + toggle.setAttribute('aria-label', locale === 'ru' ? 'Выбрать анимацию' : 'Choose animation'); + menu.setAttribute('aria-label', locale === 'ru' ? 'Анимации' : 'Animations'); + }; + renderLabels(); + window.addEventListener('message', (event) => { + if (event.source !== parent || event.data?.source !== 'canvastty-host' || event.data?.type !== 'context') return; + const hostLocale = event.data.value?.appearance?.locale; + if (hostLocale !== 'ru' && hostLocale !== 'en') return; + locale = hostLocale; + renderLabels(); + }); + toggle.addEventListener('click', () => { + const open = picker.classList.toggle('open'); + toggle.setAttribute('aria-expanded', String(open)); + if (open) menu.querySelector('button')?.focus(); + }); + document.addEventListener('pointerdown', (event) => { if (!picker.contains(event.target)) close(); }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && picker.classList.contains('open')) { close(); toggle.focus(); } + }); + menu.addEventListener('keydown', (event) => { + const items = [...options.values()]; + const current = items.indexOf(document.activeElement); + let next = current; + if (event.key === 'ArrowDown') next = (current + 1) % items.length; + else if (event.key === 'ArrowUp') next = (current - 1 + items.length) % items.length; + else if (event.key === 'Home') next = 0; + else if (event.key === 'End') next = items.length - 1; + else return; + event.preventDefault(); + items[next]?.focus(); + }); + } + + const step = async (last) => { + if (mode === 'idle') { await play('idle'); return last; } + if (mode !== 'all') { + const action = mode; + await perform(action); + await play('idle'); + return action; + } + const deck = deal(last); + if (!deck.length) { if (has('idle')) await play('idle'); else await sleep(1); return last; } + for (const action of deck) { + await play('idle', chance(0.6) ? 1 : 2); + if (mode !== 'all') break; + await perform(action); + last = action; + if (mode !== 'all') break; + } + return last; + }; + + (async () => { + await ready; // start only when every sheet is decoded + let last = ''; + for (;;) last = await step(last); + })(); +})(); diff --git a/assets/mascot-pipeline/tests/placement_regression.py b/assets/mascot-pipeline/tests/placement_regression.py new file mode 100644 index 00000000..85d7bd95 --- /dev/null +++ b/assets/mascot-pipeline/tests/placement_regression.py @@ -0,0 +1,31 @@ +"""Distinguish placement drift from unchanged-source translation using real art.""" +import hashlib +import json +from pathlib import Path +import numpy as np +from PIL import Image + +root = Path(__file__).resolve().parent.parent / 'examples' +case = json.loads((root / 'placement-regression/case.json').read_text()) +paths = {'aligned': root / 'motion-library/set-c/atlas-0.webp', + 'unaligned': root / 'placement-regression/unaligned.webp'} +rows = {} +cw, ch = case['cell'] +for kind, path in paths.items(): + assert hashlib.sha256(path.read_bytes()).hexdigest() == case[f'{kind}_sha256'] + atlas = Image.open(path).convert('RGBA') + rows[kind] = [] + for record in case['regions']: + i = record['index'] + x, y = i % case['cols'] * cw, i // case['cols'] * ch + cell = atlas.crop((x, y, x + cw, y + ch)) + left, top, right, bottom = record[f'{kind}_region'] + alpha = np.asarray(cell.getchannel('A'))[top:bottom, left:right] + visible = np.where(alpha > case['alpha_threshold'])[0] + assert len(visible), f'no visible footwear at cell {i}' + rows[kind].append(int(visible.max() + top)) +assert max(rows['aligned']) - min(rows['aligned']) <= case['aligned_max_range_px'] +assert max(rows['unaligned']) - min(rows['unaligned']) >= case['unaligned_min_range_px'] +assert rows['aligned'] != rows['unaligned'] +print(f'Placement regression passed: aligned rows {min(rows["aligned"])}–{max(rows["aligned"])}; ' + f'unaligned rows {min(rows["unaligned"])}–{max(rows["unaligned"])}. Artistic review remains separate.') diff --git a/assets/mascot-pipeline/tests/player-behavior.cjs b/assets/mascot-pipeline/tests/player-behavior.cjs new file mode 100644 index 00000000..f6d5f92f --- /dev/null +++ b/assets/mascot-pipeline/tests/player-behavior.cjs @@ -0,0 +1,88 @@ +// Exercise the real player with a deterministic clock; no browser or artwork is required. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, '../templates/plugin/player.js'), 'utf8'); +const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); }; + +async function player(withIdle = true, action = 'a') { + const drawn = [], timers = []; + const element = () => ({ + style: {}, dataset: {}, children: [], listeners: {}, attributes: {}, + classList: { remove() {}, contains() { return false; }, toggle() { return true; } }, + addEventListener(name, fn) { this.listeners[name] = fn; }, + setAttribute(name, value) { this.attributes[name] = value; }, + appendChild(child) { this.children.push(child); }, + focus() {}, contains() { return false; }, querySelector() { return this.children[0]; }, + setPointerCapture() {}, + getContext() { return { clearRect() {}, drawImage(_image, x) { drawn.push(x); } }; } + }); + const sprite = element(), picker = element(), toggle = element(), menu = element(); + const document = { + documentElement: element(), querySelector: () => sprite, + getElementById: (id) => ({ picker, 'picker-toggle': toggle, 'picker-menu': menu }[id]), + createElement: element, addEventListener() {} + }; + const clips = { 'a:in': [[1, .1]], a: [[2, .1], [3, .1]], 'a:out': [[4, .1]], + 'b:in': [[5, .1]], b: [[6, .1]], 'b:out': [[7, .1]] }; + if (withIdle) clips.idle = [[0, .1]]; + const data = { cell: [1, 1], cols: 8, perSheet: 8, sheets: [{ file: 'synthetic.webp' }], + deck: ['a', 'b'], clips, buildId: 'fixture' }; + const window = { SPRITE: data, location: { search: `?action=${action}` }, addEventListener() {} }; + vm.runInNewContext(source, { window, document, navigator: { language: 'en' }, + parent: { postMessage() {} }, URLSearchParams, + Image: class { complete = true; naturalWidth = 8; decode() { return Promise.resolve(); } }, + setTimeout: (fn) => timers.push(fn), Math: Object.assign(Object.create(Math), { random: () => .5 }) }); + await flush(); + return { + drawn, + select(name) { + const option = menu.children.find((item) => item.textContent === name); + assert.ok(option, `missing option ${name}`); + option.listeners.click(); + }, + async tick(count = 1) { + for (let i = 0; i < count; i++) { + assert.ok(timers.length, 'player must schedule its next frame'); + timers.shift()(); + await flush(); + } + } + }; +} + +(async () => { + const selected = await player(); + await selected.tick(6); + assert.deepEqual(selected.drawn, [1, 2, 3, 2, 3, 2, 3], 'selected action must enter once and stay in its loop'); + selected.select('a'); + await selected.tick(2); + assert.equal(selected.drawn.filter((i) => i === 1).length, 1, 'same selection must not restart entry'); + selected.select('b'); + await selected.tick(5); + assert.deepEqual(selected.drawn.slice(-5), [4, 0, 5, 6, 6], 'switch must exit, bridge through idle and loop new action'); + + const rapid = await player(); + rapid.select('b'); // A is still entering. + await rapid.tick(3); + assert.equal(rapid.drawn.at(-1), 4, 'complete a compatible cycle before exiting'); + rapid.select('a'); + rapid.select('Idle'); // Latest request wins during the exit. + await rapid.tick(3); + assert.deepEqual(rapid.drawn.slice(-3), [0, 0, 0]); + assert.ok(!rapid.drawn.includes(5), 'stale intermediate selection must not start'); + + const noIdle = await player(false, 'b'); + await noIdle.tick(3); + assert.deepEqual(noIdle.drawn, [5, 6, 6, 6], 'selected action must work without an idle clip'); + noIdle.select('a'); + await noIdle.tick(3); + assert.deepEqual(noIdle.drawn.slice(-3), [7, 1, 2]); + + const all = await player(true, 'all'); + await all.tick(20); + assert.ok(all.drawn.includes(4) && all.drawn.includes(7), 'All actions must leave actions rather than hold one forever'); + console.log('Player behavior checks passed: held action, same selection, switching, latest request, no idle, all actions.'); +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/assets/mascot-pipeline/tests/smoke_pipeline.py b/assets/mascot-pipeline/tests/smoke_pipeline.py new file mode 100644 index 00000000..056bf87b --- /dev/null +++ b/assets/mascot-pipeline/tests/smoke_pipeline.py @@ -0,0 +1,329 @@ +"""Exercise missing-frame rejection and a complete portable mascot build.""" + +import json +import hashlib +import subprocess +import sys +import tempfile +from urllib.request import urlopen +from urllib.error import HTTPError +from pathlib import Path + +from PIL import Image, ImageDraw, ImageOps + +ROOT = Path(__file__).resolve().parent.parent + + +def run(script, config, should_pass=True, extra=()): + result = subprocess.run( + [sys.executable, str(ROOT / 'scripts' / script), str(config), *extra], + text=True, + capture_output=True, + check=False, + ) + if (result.returncode == 0) != should_pass: + raise AssertionError(f'{script} returned {result.returncode}\n{result.stdout}\n{result.stderr}') + return result + + +def draw_frame(path, hand_height): + image = Image.new('RGBA', (128, 192), (0, 0, 0, 0)) + pen = ImageDraw.Draw(image) + pen.ellipse((53, 25, 75, 47), fill=(235, 190, 180, 255)) + pen.rectangle((50, 48, 78, 125), fill=(70, 90, 175, 255)) + pen.rectangle((52, 126, 59, 164), fill=(40, 40, 60, 255)) + pen.rectangle((69, 126, 76, 164), fill=(40, 40, 60, 255)) + pen.line((77, 60, 91, hand_height), fill=(235, 190, 180, 255), width=5) + pen.rectangle((25, 70, 38, 83), fill=(80, 120, 210, 80)) + image.save(path) + + +def approve_fixture(project): + build = json.loads((project / 'build-report.json').read_text(encoding='utf-8')) + manifest = {'limits': {'support_px': 2, 'landmark_px': 100, 'size_fraction': .1}, 'frames': { + name: {'source_sha256': record['sha256'], + 'supports': [{'name': 'feet', 'region': [50, 150, 80, 172], 'alpha_threshold': 128, 'grounded': True}], + 'landmarks': {'face': [64, 36], 'hips': [64, 125]}, + 'sizes': {'head_width': 23, 'torso_length': 78}, + 'anatomy': 'Synthetic connected arm and two legs.', 'contact': 'Synthetic feet support.', + 'face': 'Synthetic static fixture; no generated character approval.'} + for name, record in build['frames'].items() if name in build['placement']['frames']}} + (project / 'continuity.json').write_text(json.dumps(manifest), encoding='utf-8') + run('review_continuity.py', project / 'character.json') + continuity = json.loads((project / 'continuity-report.json').read_text(encoding='utf-8')) + review = {'build_id': build['build_id'], 'user_approved': True, 'installation_approved': True, + 'checks': {key: 'Synthetic fixture, no user artwork.' for key in + ('source', 'scale', 'neighbors', 'materials_contact', 'effects', 'main_loop', 'boundaries', 'browser')}, + 'phases': {'wave': {'raise': 'Synthetic arm raised.', 'peak': 'Synthetic peak.'}}, + 'coverage': {clip: {'steps': list(range(record['playback_steps'])), 'note': 'Synthetic test coverage.'} + for clip, record in build['clips'].items()}, + 'playback': {'wave': {'main_loops': 2, 'entrance': True, 'exit': True, 'note': 'Synthetic test evidence.'}}, + 'continuity_manifest_sha256': continuity['manifest_sha256'], + 'continuity_report_sha256': hashlib.sha256((project / 'continuity-report.json').read_bytes()).hexdigest(), + 'continuity_decisions': {}, 'unresolved': []} + review['observations'] = {key: {'mode': 'observed_current_build', 'build_id': build['build_id'], + 'evidence': 'Synthetic test observation; no user artwork.'} for key in review['checks']} + (project / 'review.json').write_text(json.dumps(review), encoding='utf-8') + + +def main(): + with tempfile.TemporaryDirectory() as temporary: + opaque = Path(temporary) / 'opaque.png' + Image.new('RGB', (32, 32), (255, 255, 255)).save(opaque) + rejected = subprocess.run( + [sys.executable, str(ROOT / 'scripts' / 'prepare_input.py'), + '--image', str(opaque), '--project', str(Path(temporary) / 'rejected')], + text=True, capture_output=True, check=False, + ) + assert rejected.returncode == 0, rejected.stderr + assert 'background removal required: True' in rejected.stdout + jpeg = Path(temporary) / 'photo.JPEG' + photo = Image.new('RGB', (32, 48), (35, 75, 125)) + ImageDraw.Draw(photo).rectangle((0, 0, 12, 18), fill=(220, 30, 40)) + exif = Image.Exif() + exif[274] = 6 + photo.save(jpeg, exif=exif) + jpeg_project = Path(temporary) / 'jpeg-project' + normalized = subprocess.run([sys.executable, str(ROOT / 'scripts' / 'prepare_input.py'), + '--image', str(jpeg), '--project', str(jpeg_project)], + text=True, capture_output=True, check=False) + assert normalized.returncode == 0, normalized.stderr + assert (jpeg_project / 'references' / 'character.jpg').read_bytes() == jpeg.read_bytes() + with Image.open(jpeg) as original, Image.open(jpeg_project / 'references' / 'character.png') as working: + expected = ImageOps.exif_transpose(original).convert('RGBA') + assert working.size == (48, 32) + assert working.convert('RGBA').tobytes() == expected.tobytes(), 'JPEG normalization must only orient/encode decoded pixels' + impostor = Path(temporary) / 'not-a-jpeg.jpg' + Image.new('RGB', (32, 32)).save(impostor, format='GIF') + invalid = subprocess.run([sys.executable, str(ROOT / 'scripts' / 'prepare_input.py'), + '--image', str(impostor), '--project', str(Path(temporary) / 'invalid-input')], + text=True, capture_output=True, check=False) + assert invalid.returncode != 0 + assert not (Path(temporary) / 'invalid-input').exists() + intake = Path(temporary) / 'upload.png' + draw_frame(intake, 100) + project = Path(temporary) / 'project' + prepared = subprocess.run( + [sys.executable, str(ROOT / 'scripts' / 'prepare_input.py'), + '--image', str(intake), '--project', str(project)], + text=True, capture_output=True, check=False, + ) + assert prepared.returncode == 0, prepared.stderr + assert (project / 'references' / 'character.png').is_file() + (project / 'references' / 'character.jpg').write_bytes(jpeg.read_bytes()) + config = json.loads((ROOT / 'examples' / 'character.example.json').read_text(encoding='utf-8')) + config['name'] = 'Sample "Mascot"' + config['description'] = 'A mascot with a "quoted" name.' + config['clips']['wave'] = [['wave-01', 0.25], ['wave-02', 0.25], ['wave-03', 0.35]] + config['deck'] = ['wave'] + config['labels']['wave'] = 'Wave' + config['required_phases'] = {'wave': [ + {'id': 'raise', 'clip': 'wave', 'frames': ['wave-01', 'wave-02']}, + {'id': 'peak', 'clip': 'wave', 'frames': ['wave-03']}, + ]} + config_path = project / 'character.json' + config_path.write_text(json.dumps(config), encoding='utf-8') + frames = project / 'frames' + for name, hand in [('idle-01', 100), ('idle-02', 99), ('wave-01', 90), ('wave-02', 75), + ('to-wave', 100), ('from-wave', 100)]: + draw_frame(frames / f'{name}.png', hand) + with Image.open(frames / 'wave-02.png') as original: + effect = original.copy() + ImageDraw.Draw(effect).rectangle((30, 178, 35, 182), fill=(200, 210, 230, 4)) + effect.save(frames / 'wave-02.png') + + missing = run('qa_frames.py', config_path, should_pass=False) + assert 'wave-03' in missing.stderr + assert 'wave-03' in (frames / 'qa-report.json').read_text(encoding='utf-8') + Image.new('RGBA', (100, 192), (255, 0, 0, 255)).save(frames / 'wave-03.png') + invalid = run('qa_frames.py', config_path, should_pass=False) + assert 'SIZE' in invalid.stderr + draw_frame(frames / 'wave-03-fixed.png', 60) + + run('qa_frames.py', config_path) + assert (frames / 'qa-sheet.png').is_file() + report = json.loads((frames / 'qa-report.json').read_text(encoding='utf-8')) + repaired = next(frame for frame in report['frames'] if frame['name'] == 'wave-03') + assert repaired['source'] == 'wave-03-fixed.png' + assert {'to-wave', 'from-wave'} <= {frame['name'] for frame in report['frames']} + run('scaffold_plugin.py', config_path) + run('build_sprite.py', config_path) + run('build_sprite.py', config_path) + pending = run('finalize_mascot.py', config_path, should_pass=False) + assert 'visual review is pending' in pending.stderr + run('qa_frames.py', config_path, extra=('--approve', 'Full-size frame, face, alpha edge, scale and motion review passed.')) + pending = run('finalize_mascot.py', config_path, should_pass=False) + assert 'review.json is missing' in pending.stderr + approve_fixture(project) + review_path = project / 'review.json' + review = json.loads(review_path.read_text()) + del review['phases']['wave']['peak'] + review_path.write_text(json.dumps(review), encoding='utf-8') + incomplete = run('finalize_mascot.py', config_path, should_pass=False) + assert 'phase was not visually reviewed' in incomplete.stderr + approve_fixture(project) + review = json.loads(review_path.read_text()) + review['coverage']['wave']['steps'].pop() + review_path.write_text(json.dumps(review), encoding='utf-8') + incomplete = run('finalize_mascot.py', config_path, should_pass=False) + assert 'every built step' in incomplete.stderr + approve_fixture(project) + continuity_path = project / 'continuity-report.json' + continuity_path.write_text(continuity_path.read_text() + '\n', encoding='utf-8') + stale = run('finalize_mascot.py', config_path, should_pass=False) + assert 'continuity evidence/review is stale' in stale.stderr + approve_fixture(project) + review = json.loads(review_path.read_text()) + review['installation_approved'] = False + review_path.write_text(json.dumps(review), encoding='utf-8') + refused = run('finalize_mascot.py', config_path, should_pass=False, extra=('--install-approved',)) + assert 'explicit approval' in refused.stderr + approve_fixture(project) + run('finalize_mascot.py', config_path) + assert json.loads((project / 'mascot-result.json').read_text())['status'] == 'reviewed' + run('finalize_mascot.py', config_path, extra=('--install-approved',)) + + # Draft consent keeps the full plan and known defects; it cannot become final approval. + full_plan = json.loads(config_path.read_text()) + full_plan['required_phases']['wave'].append({'id': 'squat', 'clip': 'wave', 'frames': ['not-drawn']}) + full_plan_path = project / 'character-full-plan.json' + full_plan_path.write_text(json.dumps(full_plan), encoding='utf-8') + review = json.loads(review_path.read_text()) + review['full_plan_sha256'] = hashlib.sha256(full_plan_path.read_bytes()).hexdigest() + review_path.write_text(json.dumps(review), encoding='utf-8') + assert 'full-plan phases' in run('finalize_mascot.py', config_path, should_pass=False).stderr + draft = dict(review, user_approved=False, draft_approved=True, deferred_phases=['wave/squat'], + unresolved=['Sharp transition.'], limitations=['wave/squat', 'Sharp transition.']) + draft_path = project / 'draft-review.json' + draft_path.write_text(json.dumps(draft), encoding='utf-8') + run('finalize_mascot.py', config_path, extra=('--draft', '--install-approved')) + result = json.loads((project / 'mascot-result.json').read_text()) + assert result['delivery'] == 'draft' and result['limitations'] == draft['limitations'] + run('finalize_mascot.py', config_path, extra=('--draft', '--verify-only', '--install-approved')) + draft['limitations'] = ['wave/squat'] + draft_path.write_text(json.dumps(draft), encoding='utf-8') + assert 'retain all unresolved' in run('finalize_mascot.py', config_path, should_pass=False, extra=('--draft',)).stderr + draft['limitations'].append('Sharp transition.') + draft['observations']['main_loop']['mode'] = 'reused_unchanged_pixels' + draft_path.write_text(json.dumps(draft), encoding='utf-8') + assert 'fresh current-build' in run('finalize_mascot.py', config_path, should_pass=False, extra=('--draft',)).stderr + full_plan_path.unlink() # Remove only this synthetic fixture's expanded draft plan. + approve_fixture(project) + run('finalize_mascot.py', config_path, extra=('--install-approved',)) + + package = project / 'plugin' + manifest = json.loads((package / 'canvastty.plugin.json').read_text(encoding='utf-8')) + assert manifest['id'] == config['id'] + assert manifest['name'] == config['name'] + assert manifest['description'] == config['description'] + sprite = json.loads((package / 'frames.js').read_text(encoding='utf-8').removeprefix('window.SPRITE = ').removesuffix(';\n')) + assert manifest['contributions'][0]['defaultSize']['width'] == max( + 128, round(manifest['contributions'][0]['defaultSize']['height'] * sprite['cell'][0] / sprite['cell'][1]) + ) + assert manifest['contributions'][0]['minSize']['width'] == 128 + assert (package / 'sprite-0.webp').is_file() + alpha = Image.open(package / 'sprite-0.webp').getchannel('A') + assert alpha.histogram()[40:110] != [0] * 70 + assert alpha.histogram()[4] > 0, 'soft alpha must not be thresholded away' + index = sprite['clips']['wave'][1][0] + sheet_index, local = divmod(index, sprite['perSheet']) + with Image.open(package / sprite['sheets'][sheet_index]['file']) as atlas: + x = local % sprite['cols'] * sprite['cell'][0] + y = local // sprite['cols'] * sprite['cell'][1] + assert atlas.getpixel((x + 60, y + 30)) == effect.getpixel((60, 30)), 'effect bounds must not move the body' + assert 'wave' in (package / 'frames.js').read_text(encoding='utf-8') + assert not any(path.name.startswith('idle-') for path in package.iterdir()) + handoff = json.loads((project / 'mascot-result.json').read_text(encoding='utf-8')) + assert handoff['status'] == 'ready_for_host' + assert handoff['actions'] == ['wave'] + assert handoff['presentation'] == {'transparentCard': True, 'resizable': True} + build = json.loads((project / 'build-report.json').read_text(encoding='utf-8')) + assert build['clips']['wave'] == {'unique_drawings': 3, 'playback_steps': 3, 'seconds': .85} + assert build['build_id'] == sprite['buildId'] + run('preview_animation.py', config_path, extra=('--action', 'wave', '--height', '96')) + assert (project / 'previews' / 'wave.webp').is_file() + assert (project / 'previews' / 'wave-sheet.png').is_file() + server = subprocess.Popen([sys.executable, str(ROOT / 'scripts' / 'serve_preview.py'), str(project)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + url = json.loads(server.stdout.readline())['url'] + with urlopen(url + 'state', timeout=5) as response: + state = json.load(response) + assert state['project'] == project.name + assert state['build'] == build['build_id'] + assert state['actions'][0]['id'] == 'wave' + assert state['review']['sprite']['frameNames'][sprite['clips']['wave'][1][0]] == 'wave-02' + assert state['review']['continuity']['frames']['wave-02']['supports']['feet']['built_y'] == 164 + assert any(item['name'] == 'character.png' for item in state['images']) + assert any(item['name'] == 'character.jpg' for item in state['images']) + for forbidden in ('character.json', '../review.json', 'plugin/'): + try: + urlopen(url + forbidden, timeout=5) + raise AssertionError('private/non-file route was exposed') + except HTTPError as error: + assert error.code == 404 + finally: + server.terminate() + server.wait(timeout=5) + + runtime = package / 'player.js' + runtime.write_text(runtime.read_text(encoding='utf-8') + '\n// Changed fixture\n', encoding='utf-8') + changed_package = run('finalize_mascot.py', config_path, should_pass=False) + assert 'plugin files changed' in changed_package.stderr + run('build_sprite.py', config_path) + rebuilt = json.loads((project / 'build-report.json').read_text()) + assert rebuilt['build_id'] != build['build_id'], 'runtime/asset changes must invalidate old build approval' + + draw_frame(frames / 'wave-02.png', 80) + run('qa_frames.py', config_path) + run('qa_frames.py', config_path, extra=('--approve', 'Synthetic fixture review for changed hand; no user artwork.')) + changed_source = run('finalize_mascot.py', config_path, should_pass=False) + assert 'source changed' in changed_source.stderr + run('build_sprite.py', config_path) + approve_fixture(project) + run('finalize_mascot.py', config_path) + + config['description'] = 'Updated fixture description.' + config['clips']['wave'][0][1] = .3 + config_path.write_text(json.dumps(config), encoding='utf-8') + run('qa_frames.py', config_path) + run('qa_frames.py', config_path, extra=('--approve', 'Synthetic fixture review after timing and metadata update.')) + changed_config = run('finalize_mascot.py', config_path, should_pass=False) + assert 'configuration changed' in changed_config.stderr + run('build_sprite.py', config_path) + approve_fixture(project) + run('finalize_mascot.py', config_path) + assert json.loads((package / 'canvastty.plugin.json').read_text(encoding='utf-8'))['description'] == config['description'] + + config['floor_y'] = 164 + config['cols'] = 100 + config['rows_per_sheet'] = 100 + config['ground_anchors'] = {'wave-02': 169} + config_path.write_text(json.dumps(config), encoding='utf-8') + run('build_sprite.py', config_path) + approve_fixture(project) + continuity = json.loads((project / 'continuity-report.json').read_text()) + assert continuity['frames']['wave-02']['supports']['feet']['built_y'] == 159 + assert continuity['frames']['wave-02']['supports']['feet']['source_y'] == 164 + assert any(flag['kind'] == 'support' for flag in continuity['flags']) + run('qa_frames.py', config_path) + run('qa_frames.py', config_path, extra=('--approve', 'Synthetic translated fixture.')) + refused = run('finalize_mascot.py', config_path, should_pass=False) + assert 'continuity flag needs' in refused.stderr + shifted = json.loads((package / 'frames.js').read_text(encoding='utf-8').removeprefix('window.SPRITE = ').removesuffix(';\n')) + index = shifted['clips']['wave'][1][0] + sheet_index, local = divmod(index, shifted['perSheet']) + with Image.open(package / shifted['sheets'][sheet_index]['file']) as atlas: + assert max(atlas.size) <= 4096 + x = local % shifted['cols'] * shifted['cell'][0] + y = local // shifted['cols'] * shifted['cell'][1] + assert atlas.getpixel((x + 60, y + 25)) == (235, 190, 180, 255), 'explicit anchor translates the whole frame' + config['ground_anchors'] = {'wave-02': 0} + config_path.write_text(json.dumps(config), encoding='utf-8') + clipped = run('build_sprite.py', config_path, should_pass=False) + assert 'clips visible art' in clipped.stderr + print('Portable mascot pipeline smoke test passed') + + +if __name__ == '__main__': + main() diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 10ac6349..aa004a20 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,6 +38,7 @@ Electron main process ├── PluginAgentTools / PluginSessions / PluginCards → plugin tools in canvastty_agents, session events and owned-card control, card badges and actions ├── PluginSecretsService → OS-backed encrypted plugin credentials with fail-closed availability ├── PluginMediaService → user-granted music folders, ranged audio streams, playlist files + ├── FileAccessService → read-only, root-scoped file listing, reading, and search ├── HermesHudService → permission-gated Hermes Desktop HUD lifecycle through a fixed control contract ├── BrowserService → tabs, shared persistent profile, downloads, presence, WebContentsView lifecycle │ ├── BrowserStore / BrowserPolicyService / BrowserAuditStore @@ -51,7 +52,7 @@ Electron main process - `src/shared/contracts.ts` is the single public contract between processes. Add or change cross-process data here first. - `src/preload/index.ts` exposes only the typed capabilities the renderer needs. Node integration stays disabled; context isolation and sandbox stay enabled. - Terminal file drops resolve native `File` objects through preload's `webUtils.getPathForFile`, format paths for the host's default shell, and paste through xterm without submitting. File contents are not read and no new main-process IPC is exposed. -- `src/main/ipc/registerIpc.ts` owns native side effects and validates access to persisted media. +- `src/main/ipc/registerIpc.ts` owns native side effects, validates access to persisted media, and guards the allow-listed `files:*` channels with the trusted-main-renderer check. - `src/main/services/TerminalManager.ts` is the source of truth for live session state and PTY buffers. It keeps scrollback in a bounded chunk buffer and coalesces PTY data into 16ms IPC batches so clear/redraw sequences reach xterm together. A plain terminal starts `idle`; an agent stays `unavailable` until its provider emits a machine-readable lifecycle signal. Codex, Claude Code, Qwen Code, Kimi Code, OpenCode, Hermes, and Grok Build then transition through `idle`, `working`, and `needs_approval` from provider hooks; exact Claude/Qwen OSC 0/2 markers remain a compatibility fallback. Human-readable terminal text and PTY existence are never treated as activity. Process exit provides only `done` or `failed`. An exited PTY may be restarted under the same session ID while preserving its card, bounds, title, and scrollback. Optional restart persistence (Settings → General, "Agent sessions after restart": Don't save / Reopen windows / Continue conversations) writes session records v2 through `TerminalSessionStore`: the card descriptor, the state at quit or exit, the provider conversation id a lifecycle hook reported (`threadId`: Codex thread or Claude session UUID, OpenCode `ses_` id), the per-card restore flag, and two opaque plugin slots (launch options and an environment reference, at most 4 KB each). It never writes PTY scrollback, prompts, child environment, secrets, or capabilities. Plain terminals reopen as fresh shells in their saved folder. - `src/renderer/src/features/terminal/webglContextPool.ts` decides which terminal cards draw with xterm's WebGL renderer. Chromium keeps at most 16 WebGL contexts per renderer process, so the pool hands out 10: the focused card first, then by on-screen area (a busy holder keeps its context against a card less than 1.25× larger), then by most recent output. Camera and layout changes settle for 200 ms before contexts move; a card that leaves the screen, zooms above 1× or enters summary mode releases its context (and explicitly loses it) at once. Every other card uses the DOM renderer. A lost context puts the card back on DOM with its buffer intact and keeps it there for 30 s, doubling on repeats; returning to DOM refits the grid, because WebGL cells are snapped down to whole device pixels. - `src/main/services/LimitsService.ts` reads Codex through the installed CLI's app-server protocol and Claude, Kimi, OpenCode Go, and Grok Build through their provider usage or billing endpoints. Qwen Code is multi-provider and exposes no provider-neutral read-only quota protocol, so its adapter reports `cli-not-found` or `unsupported-protocol` and never invents percentages. Provider credentials are read only inside the trusted main process, sent only to the matching provider over HTTPS, and never logged or exposed over IPC. The service owns timeout, structural normalization, caching, stale fallback, and subprocess cleanup; raw provider responses never cross IPC. @@ -64,6 +65,7 @@ Electron main process - `src/main/services/PluginAgentTools.ts` lists trusted services' `tools` per session role (the orchestration handler answers the helper's `list_tools`; `TerminalManager` gives a non-orchestrator card the `canvastty_agents` bridge only when a plugin tool lists its role) and routes `__` calls to `canvastty.tools.call`, masked and bounded. `PluginSessions.ts` turns terminal-manager events into `canvastty.sessions.event` notifications (metadata; masked screen text only with `sessions:read-screen`) and runs `sessions.create/send/stop` with per-plugin ownership saved in the card's session record (`ownerPluginId`). `PluginCards.ts` keeps plain-text badges and declared card actions, pushes them to the renderer (`plugins:card-decorations-changed`) and calls `canvastty.cards.invoke`. - `src/main/services/PluginSecretsService.ts` serializes per-plugin secret writes, encrypts the complete bounded payload through Electron `safeStorage`, rejects plaintext-only backends, and removes each encrypted file on uninstall. `ProviderSecretsService.ts` applies the same architecture to provider API keys for BYOK-capable CLIs: values stay in the main process, and the renderer contract exposes only per-key `configured` flags plus set/clear actions. `ApiProfile` settings entries name model backends (protocol, HTTPS base URL, secret reference) for the same BYOK runtimes; they are not agent providers, and the settings normalizer drops invalid profiles instead of repairing them. - `src/main/services/PluginMediaService.ts` persists per-plugin grants only after a native folder choice, hides absolute paths, skips symlinks, and serves contained audio with HTTP Range semantics. Playlist reads stay inside granted libraries; writes are bounded and atomic under the library's `Playlists/` directory. +- `src/main/services/FileAccessService.ts` is the single owner of read-only, root-scoped file access. A root is registered either from a terminal session's working directory, which main resolves through `TerminalManager`, or from a folder the user picked in the native dialog. The renderer receives only an opaque `rootId` and a display label, and addresses content with relative paths; the `files:*` channels (`list-roots`, `register-root`, `open-folder`, `list`, `read`, `search`, `close-root`) never carry an absolute path for list/read/search. Every operation canonicalizes root and target with `realpath`, rejects absolute paths, `..` traversal, symlinks that resolve outside the root, and non-regular files, and re-checks containment around the open. Text returns bounded UTF-8 with a `truncated` flag and total size, images return a preview under a MIME allow-list and byte cap, and binary content returns an explicit unsupported reason with no bytes. Directory listings are capped and ordered directories-before-files; search is bounded by depth and result count and skips VCS and dependency directories. Roots are released when their card closes and re-validated on restore, and there is no write, rename, or delete path. - `src/main/services/HermesHudService.ts` is the only plugin-facing native application controller. It resolves the installed Hermes CLI through the immutable provider registry, sends only the fixed `--hud`/`--quit` control commands, and derives visible state from Hermes Desktop's validated live runtime record. It never accepts executable paths, arguments, PIDs, or arbitrary commands from plugin code. - `src/main/services/BrowserService.ts` is the only owner of the built-in browser's `WebContentsView` tabs and shared persistent partition. Remote pages have no preload or Node access, keep context isolation and sandbox enabled, and cannot request hardware, location, notification, clipboard-read, certificate-bypass, or external-protocol capabilities. HTTP(S) popups are adopted as internal tabs; other schemes are rejected. - `src/main/services/browser/` contains the browser kernel. `BrowserStore` atomically persists only tab order, active tab, and safe restore URLs. `BrowserPolicyService` centralizes URL, permission, download, and upload rules; validated uploads are copied through an already-open no-follow file descriptor into private staging before Chromium sees them. `BrowserAutomationService` attaches Electron's internal debugger to the existing live tab without a remote-debugging port. `BrowserCommandDispatcher` adds revisions, revision-bound refs, mutation request deduplication, per-tab FIFO mutation lanes, bounded concurrency, typed errors, and redacted fail-closed audit for agent mutations. @@ -102,6 +104,7 @@ App │ ├── CanvasCommandPalette searchable sessions and the same global creation/launch actions │ ├── PluginCanvasCard sandboxed plugin app with canvas bounds and semantic summary │ ├── BrowserCard trusted browser chrome and canvas geometry for the native WebContentsView +│ ├── FileBrowserCard read-only tree and viewer for files inside a registered root │ └── CanvasMinimap viewport/entity overview, camera recentering, and canvas-direction drag panning ├── AgentLaunchDialog fixed provider + folder + profile + launch └── SettingsPanel two-pane icon-sidebar modal for General, Appearance, Agents, Controls, Browser, Plugins, and About @@ -127,7 +130,7 @@ When session restore is on, startup loads validated records before the renderer `SessionMetadata` owns both world-space position and card size. `App` reconciles those bounds, while `TerminalCard` may hold transient pointer-move geometry until pointer-up. The main process validates and clamps committed sizes before emitting a session snapshot. Camera wheel handling is limited to empty canvas; interactive surfaces keep their native scroll/input ownership. -The camera is not React state. `App` owns a `cameraStore` (`features/workspace/cameraStore.ts`); `WorkspaceCanvas` writes the scene transform from the store's listener, synchronously and before anything that measures the scene renders. Only the minimap's camera rectangle and `BrowserCard` subscribe to every move; cards subscribe to derived values (`useCameraSelector`: summary scale, WebGL eligibility) and drag handlers read `camera.get().zoom` when they move, so a pan or zoom renders no card. The minimap fits HOME and occupied canvas bounds independently of the camera. Its workspace layer is memoized by geometry, provider, region color and layer order, ignoring session output, titles and activity; camera updates project only one viewport rectangle in constant time, without rebuilding the object list or provider icons. +The camera is not React state. `App` owns a `cameraStore` (`features/workspace/cameraStore.ts`); `WorkspaceCanvas` writes the scene transform from the store's listener, synchronously and before anything that measures the scene renders. Only `CanvasMinimap` and `BrowserCard` subscribe to every move; cards subscribe to derived values (`useCameraSelector`: summary scale, WebGL eligibility) and drag handlers read `camera.get().zoom` when they move, so a pan or zoom renders no card. The minimap fits the camera viewport together with HOME and occupied canvas bounds, updating the overview scale and projected rectangles during navigation. Its content inputs are memoized by geometry, provider, region color and layer order, ignoring session output, titles and activity. Minimap drag retains the starting camera and overview scale for the whole pointer gesture. A live `TerminalCard` owns one xterm instance for the lifetime of its session ID. Palette changes update `terminal.options.theme` in place; title and settings changes must never dispose the terminal or its renderer-side scrollback. Window titles are updated as session metadata through `terminal:rename`. PTY input and resize events that race with process exit are contained at the main-process boundary and never surface as uncaught Electron errors. diff --git a/docs/README.md b/docs/README.md index cabf3fdb..3e0c19d1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,12 +10,14 @@ CanvasTTY is a spatial Electron desktop for real local terminals and AI-agent CL |:--|:--| | [Getting started](getting-started.md) | Requirements, local launch, first session, and verification commands | | [Built-in browser and audit log](browser.md) | Canvas controls, settings, agent access, website/file boundaries, activity, and persistent redacted audit files | +| [File browser and reader](files.md) | Opening a Files card, session and folder roots, supported content, read limits, quick open, persistence, and read-only security boundaries | | [Web companion](mobile-companion.md) | Tailscale Serve HTTPS setup, browser pairing, session grants, revocation, and security limits | | [Installing, releases, and local data](installing-and-security.md) | Installer formats, unsigned-preview caveats, credential boundaries, and release checks | | [Widget authoring](widget-authoring.md) | Source-level extension paths, visual grammar, process boundaries, and an AI-agent brief | | [Pixel terminal themes (Russian)](pixel-skin-packs.ru.md) | Ten-PNG ZIP layout, terminal apertures, app import, and local agent CLI | | [Create a pixel theme with an agent](pixel-skin-agent-start.md) | Zero-context agent brief, exact install ZIP, visual checks, and validation | | [Runtime plugins](plugins.md) | Manifest v1, permissions, HOME widgets, canvas apps, separate windows, player media/playlist APIs, SDK, and install flow | +| [Animated mascots](mascots.md) | PNG/JPEG intake, a Codex creation session, live previews, frame review, transparent cards, approved installation, and retry diagnostics | | [Metrics and telemetry](metrics-and-telemetry.md) | Subscription limits, session token usage, source priority, privacy, stale states, and tests | | [Terminal renderer transition check](terminal-renderer-transition.md) | Manual hide/restore check for the DOM/WebGL transition snapshot | | [Security policy](../SECURITY.md) | Supported release, vulnerability reporting, local data boundaries, plugins, media grants, browser storage, and audit logs | diff --git a/docs/UI_CONTRACT.md b/docs/UI_CONTRACT.md index 581c6ca8..7acaf490 100644 --- a/docs/UI_CONTRACT.md +++ b/docs/UI_CONTRACT.md @@ -60,6 +60,8 @@ This contract preserves the approved MVP concept and prevents feature ownership - Controls includes an off-by-default **Copy on selection** setting, persisted and applied immediately to every agent and terminal, including fullscreen cards. Completing a non-empty mouse selection copies its text to the system clipboard without clearing the highlight; search results and cleared selections do not copy. With the setting enabled, Shift forces text selection in mouse-reporting TUIs on all desktop platforms; on macOS the terminal mouse adapter translates it to xterm's Option selection without taking the canvas's Option navigation binding. - Canvas plugin apps use the same movable card grammar, `54px` header, resize/snap behavior, and semantic summary below `0.5×`. A `window` contribution opens a separate CanvasTTY-owned sandboxed window; arbitrary native window embedding is not part of the contract. - The built-in Browser is one movable, resizable core canvas card, not a plugin contribution. It uses the same `54px` outer window header as other canvas cards, with its identity and hide-card action isolated from the internal tab strip below. Its trusted DOM chrome owns compact tabs/favicons, address/search, back/forward/reload, downloads, per-tab provider badges, site dialogs, and explicit Close tab/Close all. Hiding the card keeps its tabs and shared authenticated Chromium profile alive. Below `0.5×`, during Edit HOME, behind trusted dialogs/popovers, and while overlapped by a higher renderer-owned canvas window, the native page is hidden and replaced with a stable semantic surface. During card or camera motion it remains live and follows frame-coalesced viewport geometry. +- The Files card is a movable, resizable trusted canvas card using the same `54px` header, edge/corner resize, snapping, focus, stacking, minimap marker, and close grammar as other cards. Its header shows the root label and the active file; its body pairs a tree that expands directories on demand with a read-only viewer, and quick open searches file names inside the active root. Text and code render as selectable monospace text with a truncation banner, supported images render as a preview, and binary, oversized, unavailable, or out-of-root content shows an explicit non-content state with the reason. Below `0.5×` it collapses to the shared semantic summary. It never exposes a write, rename, or delete action. +- The Files viewer renders Markdown (`.md`, `.markdown`, `.mdown`, `.mkd`) as formatted GitHub-Flavored Markdown with fenced code blocks syntax-highlighted, and syntax-highlights recognized code files by the language detected from their extension while unknown languages stay plain monospaced text. All rendered output is sanitized: raw HTML and scripts never render and event-handler attributes and dangerous link schemes such as `javascript:` are neutralized. Activating an `http(s)` link opens it in the built-in browser while other schemes are inert, only embedded `data:` images render while remote or relative sources fall back to alt text or a placeholder without a network request, and text or code above the rich-rendering size threshold falls back to plain text with a notice. - An authenticated connection or heartbeat alone never shows agent presence. A branded badge appears only after that agent actually issues a browser command, and its cursor appears only after a real pointer position exists. Claude uses `#D97757`, Codex `#10A37F`, Qwen `#6D44E8`, Kimi `#7C5CFC`, OpenCode `#5A5858`, Hermes `#D6A700`, and an unknown provider `#7A8291`. Browser Settings separately controls indicator visibility without revoking browser access; the agent-access kill switch revokes the connection itself. - With window snapping enabled, drag and resize use a hidden `10px` grid and a `10px` magnetic threshold for neighboring edges, centers, and a consistent `20px` gap. - One trusted context dispatcher owns right-clicks on the canvas. Truly empty canvas offers region creation, a sticky note, the Settings-configured launcher submenu, Browser, and Settings. Holding RMB on empty canvas also opens the configurable radial quick launcher: moving toward an action and releasing activates it, while a stationary right-click continues to open the standard empty-canvas menu. Region and note right-clicks remain context-specific. A region offers rename, color, a note at the pressed point, and region-only deletion. A note frame offers edit, bring-to-front, and deletion. Terminal, Browser, plugin, textarea/input, and contenteditable surfaces keep their native context menus; middle-button panning is unchanged. `Cmd/Ctrl+K` opens a searchable palette over live sessions and the same global creation/launch actions. Regions persist independently of terminal restore, render behind HOME and windows, resize from every edge/corner, and move every terminal, plugin, Browser, or note whose complete bounds were inside the region at drag start. Attached contents follow the region continuously during the gesture and persist once at pointer release; deleting a region never deletes its contents. Sticky notes persist bounded text and geometry, snap like other windows, and resize from every edge/corner. General Settings exposes independent **Save colored regions after exit** and **Save notes after exit** controls. Both default on to preserve existing profiles. Turning either off removes only that collection from disk persistence without deleting its live canvas objects; turning it back on saves the current collection, while leaving it off starts the next launch with that collection empty. @@ -67,8 +69,8 @@ This contract preserves the approved MVP concept and prevents feature ownership - Settings uses the approved two-pane modal grammar: a persistent icon-labelled sidebar for all seven sections, a separate current-section header, and a scrollable content pane. Settings must not fall back to the legacy light slide-over with one horizontal segmented tab strip. Simple settings use compact copy/control rows; all Settings chrome reads the active system tokens, official Lucide assets, and root `--ui-scale`. - General Settings places the macOS/Windows/Linux/Custom keyboard preset after language, followed by an expandable Keyboard shortcuts section for canvas, terminal, Codex editor and canvas navigation bindings. Recording a shortcut selects Custom; choosing a named preset replaces bindings. Optional commands can be disabled, while submit, newline and whole-draft selection remain bound. Canvas and terminal bindings update immediately; native Codex editor bindings apply to new or restarted cards. Focused terminal and text inputs retain ownership of canvas keyboard shortcuts on every platform. - Empty-canvas drag and middle-button drag pan the canvas. On fresh profiles ordinary scroll over empty canvas pans on both axes; pinch and `Cmd/Ctrl + scroll` zoom around the pointer. **Use scroll wheel to zoom** restores historical ordinary-wheel zoom and is enabled by migration for existing profiles. Only a focused input widget interrupts canvas wheel/pinch, except the HOME session list keeps ordinary local scrolling without a focus precondition; unfocused or non-focusable widgets otherwise leave those events to the canvas. **Wheel/pinch capture over widgets** has Off, On, and Key modes: Off preserves local ownership, On always transfers only wheel/pinch to the canvas, and Key transfers only wheel/pinch while its binding is held. Key and full-navigation bindings accept modifier chords or Mouse3/Mouse4/Mouse5. A focused live Browser page therefore scrolls normally in Off or released-Key mode; unfocused Browser content, On, an active Key binding, the full override, pinch, and `Cmd/Ctrl + scroll` are canvas-owned. The owner selected for a Browser wheel sequence is held until 250 ms of wheel inactivity. Browser summaries and placeholders are always canvas-owned. Fresh profiles use Key with `Command` on macOS or `Ctrl` elsewhere. A separate configurable **Canvas navigation override** (default `Option` on macOS and `Alt` elsewhere) transfers wheel/pinch and drag while held. While full override is held all canvas surfaces show `grab`; an owned drag shows `grabbing` until pointer up/cancel even after the binding is released. -- The minimap fits the occupied workspace: HOME, regions, terminal/agent windows, renderable plugin cards, the visible Browser card, and sticky notes. It uses one uniform scale with padding, so every rectangle preserves its real proportions and relative size. Window stacking follows the Canvas. Sessions have no text labels; their existing provider icons appear only when they fit inside the projected rectangle, without enlarging it. Pan and zoom leave the overview fixed and move/resize only the white camera rectangle; a white edge marker locates the camera when its viewport is entirely outside the overview. Moving or resizing an individual window or HOME updates the layout after pointer release. Adding, closing, or repositioning objects refits the overview, and a distant object can therefore make all rectangles smaller. Controls chooses between two mutually exclusive modes: **Click** (the default, which recentres without changing zoom) and **Drag**. Drag mirrors empty-canvas grab semantics: pointer-down never jumps the camera, moving right/down pulls the scene right/down from the gesture's starting camera, and pointer capture continues outside the minimap. Pressing and releasing without movement in Drag mode never recentres the camera. Arrow keys always pan it from keyboard focus. Minimap, passive shortcut hints, and Home/zoom controls share four corner slots and stack instead of overlapping when assigned to the same corner. -- When the camera viewport contains the entire minimap overview, a white inset perimeter indicates that all of the overview is visible. The actual camera projection retains its proportions; partial overlaps keep their real projected edges rather than a closed, clipped rectangle. +- The minimap fits both the camera viewport and the occupied workspace: HOME, regions, terminal/agent windows, renderable plugin cards, Files cards, the visible Browser card, materials, and sticky notes. It uses one uniform scale with padding, so every rectangle preserves its real proportions and relative size. Window stacking follows the Canvas. Sessions have no text labels; their existing provider icons appear only when they fit inside the projected rectangle, without enlarging it. Pan and zoom refit the overview to keep the entire camera viewport and all objects visible together. Moving or resizing an individual window or HOME updates the layout after pointer release. Adding, closing, or repositioning objects also refits the overview; a distant object or camera can therefore make all rectangles smaller. Controls chooses between two mutually exclusive modes: **Click** (the default, which recentres without changing zoom) and **Drag**. Drag mirrors empty-canvas grab semantics: pointer-down never jumps the camera, moving right/down pulls the scene right/down from the gesture's starting camera, and pointer capture continues outside the minimap. Drag uses the overview scale captured at pointer-down so refitting during the gesture cannot accelerate movement. Pressing and releasing without movement in Drag mode never recentres the camera. Arrow keys always pan it from keyboard focus. Minimap, passive shortcut hints, and Home/zoom controls share four corner slots and stack instead of overlapping when assigned to the same corner. +- The white camera frame represents the full viewport and stays entirely inside the minimap's padded perimeter. It does not replace the viewport with a clipped rectangle or an edge marker. HOME and object projections retain their real proportions. - The canvas edge-pans RTS-style while the pointer rests within `56px` of a viewport edge over empty canvas; speed ramps linearly up to `900px/s` at the edge itself. Edge panning is off by default and enabled in Settings. Motion pauses over interactive surfaces (terminal cards, Home, controls) and while drag-panning. - Dialog close actions stay inside their own header/control row with a consistent inset; they never overlap a field, outline, or panel boundary. diff --git a/docs/files.md b/docs/files.md new file mode 100644 index 00000000..824938c3 --- /dev/null +++ b/docs/files.md @@ -0,0 +1,65 @@ +# File browser and reader + +[English](files.md) · [Docs home](README.md) + +CanvasTTY's **Files** card reads project files strictly inside a root you choose, without leaving the canvas. It is a trusted, read-only feature: it never writes, renames, or deletes, and it exposes no arbitrary filesystem access. + +## Open a Files card + +1. Choose **Files** from the empty-canvas context menu or the `Cmd/Ctrl+K` canvas command palette. A Files card appears on the canvas with a root selection. +2. Move, resize, focus, stack, and close it like any other canvas card. Below `0.5×` zoom it collapses to the shared semantic summary with the root label. +3. In the card, either open a session's working directory or pick a folder on disk. + +The card lives entirely on the canvas. Closing it releases its root; opening a new one starts from root selection. + +## Roots + +A root is the only directory the card can read. Every listing, read, and search is addressed by a path relative to that root. + +| Root | How it is chosen | What happens | +|:--|:--|:--| +| **Session working directory** | Select one of the live sessions offered in the card | Main resolves the session ID to its current working directory through the terminal service; the renderer never supplies a path | +| **Picked folder** | Choose the folder button, then pick a folder in the native dialog | Main canonicalizes the folder and registers it for that card | + +The renderer receives only an opaque root ID and a display label. Listings, reads, and searches never carry the absolute root path: those calls always pair the root ID with a relative path, and their payloads stay relative. The one exception is a **folder** root's descriptor, which may also carry its chosen canonical path so the card can persist and re-register that folder after relaunch; session-root descriptors omit it, so a session's working directory never crosses the bridge. + +## Supported content + +- **Markdown** — `.md`, `.markdown`, `.mdown`, and `.mkd` files are rendered as formatted GitHub-Flavored Markdown: headings, lists, task lists, tables, blockquotes, horizontal rules, inline code, and fenced code blocks with syntax highlighting. +- **Code** — recognized code files are syntax-highlighted by the language detected from the file extension, using a palette that matches the active night or day theme. A text file whose language cannot be determined is shown as readable monospaced text without highlighting and without error. +- **Images** — `png`, `jpg`/`jpeg`, `gif`, `webp`, `bmp`, and `svg` are shown as a preview with their media type. +- **Everything else** — binary content and unsupported or oversized files show an explicit non-content state with the reason. CanvasTTY never fabricates or partially decodes placeholder file data. + +Rendered Markdown and code are sanitized: raw HTML and scripts are never rendered as live markup, event-handler attributes and dangerous link schemes such as `javascript:` are neutralized, and untrusted content is never injected as raw HTML. Activating an `http(s)` link opens it in the app's built-in browser instead of navigating the viewer, while any other scheme is inert. Only embedded `data:` images render; remote or relative image sources show their alternative text or a placeholder and never trigger a network request. + +## Limits + +| Limit | Value | +|:--|:--| +| Text read | Leading `2 MB` returned, with a **truncated** marker and the total file size | +| Image read | `25 MB` maximum; larger images report **too large** and return no bytes | +| Directory listing | Up to `2,000` immediate entries | +| Search results | Up to `500` matching files | +| Search depth | Up to `12` directory levels | + +Long text reads are bounded rather than rejected: the card shows the leading portion, marks it truncated, and reports the file's total size. Binary detection never returns bytes as if they were text. Rich rendering is skipped above a roughly `250 KB` threshold: larger text and code files fall back to plain selectable monospace text with a notice that rich rendering is disabled, while the `2 MB` read cap and truncation marker still apply. + +## Quick open + +Type in the card's **Quick open** field to search file names inside the active root. Results are relative paths; select one to open it. Directories skipped during search are `node_modules`, `.git`, `dist`, `out`, `build`, and `.cache`. A query with no matches reports that plainly instead of guessing. + +## Persistence and restore + +Each Files card persists its root reference, bounds, open file, and expanded folders in app settings, consistent with other canvas entities. On relaunch the root is re-validated: a card whose root still exists restores its bounds, expanded folders, and previously open file, while a root that has moved or disappeared shows an **unavailable** state explaining that it cannot be read instead of reading an unrelated location. + +## Security boundaries + +- **Reads stay inside registered roots.** A root is either an existing terminal session working directory or a folder the user explicitly picked; there is no path into the filesystem outside one. +- **Relative paths only.** Requests address the root by opaque ID plus a relative path. Absolute paths are rejected. +- **Containment is enforced in main.** Root and target are canonicalized with `realpath` and the target must be the root itself or a descendant. Parent-directory traversal, symlinks that resolve outside the root, and non-regular files (directories, sockets, devices) are rejected, and containment is re-checked around the open. +- **Read-only by design.** There is no write, rename, delete, or arbitrary filesystem capability, and nothing is uploaded; file content stays on the machine and is never logged. +- **No content leaves the boundary.** List, read, and search payloads expose relative paths only. A folder root's descriptor alone may carry its chosen absolute path, solely so restore can re-register it; session roots never expose their working directory. + +A file that is sensitive but lives inside a root you chose (for example a project's `.env`) is readable by explicit user action. CanvasTTY never auto-expands a root beyond the directory you opened and never scans outside it. + +For service ownership and the `files:*` IPC channels, read [Architecture](ARCHITECTURE.md). For canvas and interaction invariants, read the [UI contract](UI_CONTRACT.md). diff --git a/docs/macos-pty-resources.md b/docs/macos-pty-resources.md new file mode 100644 index 00000000..77f420d4 --- /dev/null +++ b/docs/macos-pty-resources.md @@ -0,0 +1,112 @@ +# macOS PTY descriptor ownership + +CanvasTTY pins `node-pty` 1.1.0 and carries the reproducible MIT-licensed source +patch in `patches/node-pty+1.1.0.patch`. This avoids adopting an unrelated +prerelease dependency to fix a macOS resource bug. + +## Cause and patch scope + +The original macOS `pty_posix_spawn` opens temporary PTY masters to reserve +closed standard descriptors. With ordinary open stdio, its first reservation +gets an FD above 2 and exits the loop with `count == 0`. Cleanup consequently +skips that reservation. The other counter values also skip element zero and can +address past the array after allocation failures. Child spawn file-actions do +not close the parent's slave descriptor. Early errors leave partially acquired +resources behind, and the exit watcher separately retains its private kqueue. + +The upstream ownership fixes are documented in +[node-pty #882](https://github.com/microsoft/node-pty/pull/882) and +[node-pty #931](https://github.com/microsoft/node-pty/pull/931). +This backport uses scoped ownership for every temporary FD and for initialized +spawn objects. Only a successfully spawned master's ownership is transferred to +the caller; failed reservations, grant/unlock, slave setup, file-actions, attrs +and native spawn all unwind through the same cleanup. Grant and unlock failures +are checked individually. The exit kqueue is closed before its exit callback. +Nonblocking setup failures also close the descriptors acquired by that call; +a spawned child without an installed exit callback is stopped and reaped. + +The patch preserves the upstream copyright/license and does not change the +successful public API. Linux/Windows spawning does not use the macOS reservation +algorithm. + +## Build and distribution + +`npm ci` invokes the root `postinstall`: `patch-package --error-on-fail` runs +before a forced `electron-rebuild --build-from-source`. A failed source patch or +rebuild fails installation rather than silently continuing with a pristine +native binary. The dependency version and lockfile remain pinned; review/update +the patch deliberately when adopting a verified upstream fix. + +Packaging retains `npmRebuild: true`, forces `buildDependenciesFromSource`, +unpacks node-pty's runtime/native files and excludes pristine Darwin prebuilds. +The macOS smoke requires the actual `build/Release` addon and executable +`spawn-helper`, including in the separately packaged application. Skipping +installation scripts skips this installation contract; run `npm run postinstall` +before using such a developer checkout. + +## Verification + +The safe cross-host check is: + +```sh +node --test tests/node-pty-patch.test.mjs +``` + +Its C++ harness compiles the dependency's actual patched macOS function, with +allocation/spawn syscalls redirected to a descriptor ledger. It checks success, +each injected error, EINTR retry, every combination of closed stdio, foreign FD +preservation, initialized-object destruction and repeated cleanup. No real PTY +is allocated by this unit test. The original source fails the ownership check. + +The real-OS checks run ONLY in the isolated macOS GitHub Actions workflow +`.github/workflows/pty-resources.yml`, never on a user's working desktop: + +- Rebuild unpatched 1.1.0 for Electron and stop the control run at its first + measured extra-master leak (at most six measured cycles). +- Apply the tracked patch and rebuild for Electron. +- Run 400 sequential create/exit/stop cycles in ONE persistent Electron parent, + with at most one live PTY; abort immediately on descriptor growth. +- Exercise native `posix_spawn` failure with oversized argv, then create a new + PTY to demonstrate the failure did not exhaust or poison the parent. +- Check all seven nonempty combinations of closed standard descriptors. + The native ownership harness preserves the holes exactly. In full Electron, + Node/libuv can subsequently reuse a hole for its own pipe/socket/kqueue; + the integration check records that reuse, rejects PTY reuse, and still + requires unchanged master/slave/kqueue counts and stable total FD counts. +- Package a new macOS application, verify its signature and repeat the checks + using that application's actual native addon, not the development module. + +Every run records real FD counts before/after and `peakSettled` counts (the +maximum residual count after each completed cycle, not the live-phase peak). PTY masters, +slave/revoked devices and kqueues have zero leak allowance. The total allows a +symmetric difference in counts of two ordinary FDs for lsof/Node pipe-finalization timing, +with bounded settling. A failed/empty census is an error, never a zero count. +Only synthetic CI evidence and the separate test build are retained as Actions +artifacts. No personal application state, terminal buffers, credentials or +local diagnosis logs are included. + +The payload audit also scans `app.asar`, unpacked addons and application helper +resources. The pinned public `transcribe.cpp` v0.2.3 arm64 release already has +its CI builder's `__FILE__` paths in four ggml libraries. The reviewed +`scripts/fixtures/public-speech-source-path-hashes.json` records the release URL, +verified archive SHA-256 and exact hashes of its NUL-terminated source strings. +Only those strings in those four files are recognized as public build metadata; +other home paths and every credential rule still fail the audit. This does not +alter the vendor binaries, signatures or speech behavior. + +## Applying a fix is not recovering an existing process + +This change does not modify an installed application or hot-replace a loaded +native library. Existing leaked reservations/slaves/kqueues are not tracked by +node-pty's public PTY handles, nor exposed by CanvasTTY's owned-session control +API. Closing a known terminal closes its tracked master; it does not identify +or safely release arbitrary orphaned FDs. Replacing an on-disk addon also does +not replace code already loaded by Electron. + +There is no supported recovery operation here that preserves the existing +process and all user terminals. Do not inject code, close guessed descriptors, +raise kernel limits or stop unrelated sessions. Applying the separately built +fix and reclaiming an old process's leaks requires an owner-approved graceful +quit/relaunch. Review saved session/conversation restoration first: saved tabs +are not the same as still-running PTY processes. That operational restart is a +separate task, not part of this source fix or CI build. diff --git a/docs/mascots.md b/docs/mascots.md new file mode 100644 index 00000000..82aa9799 --- /dev/null +++ b/docs/mascots.md @@ -0,0 +1,88 @@ +# Animated mascots + +Settings > Mascots creates an animated canvas character from a PNG or JPEG image. CanvasTTY opens a new Codex CLI session with one image and the bundled workflow, then opens a local browser workbench for the generated drawings and animations. Creation is interactive: the user approves the character design, chooses actions, reviews the result, and separately approves installation. + +## Requirements + +- An installed, authenticated Codex CLI. CanvasTTY does not install it or provide an image-generation subscription. +- Image-generation/editing tools available to the Codex session, plus CanvasTTY's agent browser connector for live inspection. Missing generation or browser capability must be reported; file creation is not a substitute for visual review. +- Python 3.10+ with Pillow and NumPy. `CANVASTTY_MASCOT_PYTHON` can point to a Python executable in a virtual environment. Otherwise the host checks installed Python candidates, including the optional Codex desktop runtime. +- One PNG or JPG/JPEG, with or without a background, up to 20 MB and between 32 and 4096 pixels per dimension. Intake validates decoded image content, not only the filename. + +The creation dialog explains that its button starts Codex in YOLO/bypass with full file and command access. Existing provider-specific acknowledgement is retained. Starting creation also enables agent browser access when it is disabled. This workflow does not silently change the normal launch profile for other sessions. + +### Linux setup + +The mascot code uses Node path/process APIs, Python scripts and the existing Linux Codex launcher. It does not run a Windows executable or require a Windows profile. The pipeline is copied into Linux application resources by the shared packaging configuration. CanvasTTY's existing OS isolation and provider protections remain in effect; a requested bypass profile is not a claim that Linux containment is disabled. + +From a source checkout, prepare a Python environment without modifying the distribution's system Python: + +```sh +python3 -m venv "$HOME/.local/share/canvastty-mascot-venv" +"$HOME/.local/share/canvastty-mascot-venv/bin/python" -m pip install -r assets/mascot-pipeline/requirements.txt +export CANVASTTY_MASCOT_PYTHON="$HOME/.local/share/canvastty-mascot-venv/bin/python" +npm run dev +``` + +Install the distribution's Python/venv support if `python3 -m venv` is unavailable. There is no dependency on a particular distribution's package manager. Follow the repository's normal Node/Go/development requirements and install/authenticate Codex CLI separately. + +For a packaged Linux application, install Pillow and NumPy into the same environment and launch the application with `CANVASTTY_MASCOT_PYTHON` set. A desktop shortcut must inherit that variable if the interpreter cannot otherwise be found. AppImage/deb are the host application's existing release formats; this feature does not ship a separate Windows launcher or a new Linux installer. + +The existing Ubuntu `verify` CI job exercises application tests/type-check/build, including the new mascot host/launch/card regressions. The new Ubuntu `mascot-pipeline` job exercises PNG/JPEG intake, the browser preview server, atlas/review/draft/final contracts and real-art placement. Neither substitutes for a live generation session on the maintainer's distribution. + +## User workflow + +1. Open Settings > Mascots > Create mascot and choose or drop the source image. +2. Codex reads the bundled workflow. Choose whether to retain or revise the appearance. Both choices include a full-body 2D preparation pass and removal of any background; approve the prepared drawing before animation. +3. Choose a name and actions from suggested examples, or describe a custom action, pose, object interaction or scene. Keep the complete requested phase plan, even when only a draft is ready. +4. Watch the source drawings and current built player in the CanvasTTY browser. Review face/emotion changes, identity, material colors, hair silhouette, props, effects and motion. Body and object scale, support contact and neighboring frames are checked before and after atlas placement. +5. Review the identified build. Final approval and permission to install are separate decisions. An explicitly accepted draft can be installed with disclosed defects and deferred phases without pretending the complete animation is finished. +6. CanvasTTY verifies the handoff, registers the local plugin, and opens or updates its transparent canvas card. Select animations from its menu, drag the character, resize proportionally, or close it. The corner and close control appear on hover; touch retains a visible resize corner. Zooming out keeps the mascot rendering instead of replacing it with a generic summary card. + +PNG input bytes are preserved. JPEG input bytes are preserved separately, while the working PNG is normalized for EXIF orientation without a generative redraw. Preparation and generated drawings are saved separately; intake conversion alone does not satisfy character preparation. + +## Bundled workflow and examples + +The canonical English workflow is [`assets/mascot-pipeline/SKILL.md`](../assets/mascot-pipeline/SKILL.md). It contains the complete creation, prompting, measurement, visual review, draft/final approval and delivery instructions on one page. The directory can also be copied intact as a standalone Codex skill; keep its scripts, templates and examples beside `SKILL.md`. + +The portable bundle includes: + +- PNG/JPEG intake, frame QA, plugin scaffolding, atlas building, contact-sheet/playback previews, continuity measurements, a loopback workbench and finalization scripts. +- A transparent sprite player with localized action labels, menu interaction outside the drag region, separate entrance/main/exit clips and current-build identification. +- Neutral example sets with 152, 39 and 16 drawings, including accepted object interactions, facial beats and effects. Examples guide motion and review; they must not replace the new user's character identity. +- A placement regression made from the same unchanged source art: an unaligned reconstruction compared with accepted aligned output. + +Example atlases and the regression add approximately 46 MB to the source bundle before compression. They contain no personal project records, private paths or image metadata. The art serves as motion reference and test evidence; it is not a quality guarantee for future generation or a claim of ownership over depicted character designs or trademarks. + +## Local project and installation contract + +Projects live under `/mascots//`. Each project preserves its source, generated frames, `character.json`, full action plan, preview evidence, review records and built plugin. Packaged applications load the workflow from `resources/mascot-pipeline`; development loads `assets/mascot-pipeline`. The workbench binds to loopback and exposes only allowed image/player routes, not arbitrary project files. + +`finalize_mascot.py` checks current source/config/package hashes, mechanical QA, required phases, continuity evidence, all-step coverage and observation provenance. Filled review text alone cannot establish artistic quality. Actual source/playback inspection and the user's build-specific approval remain necessary. + +- Default finalization records `reviewed`, without requesting installation. +- `--install-approved` requests installation of a finally approved build. +- `--draft --install-approved` requests an explicitly accepted draft using separate `draft-review.json`, the preserved full plan, deferred phases and visible limitations. It must not claim final approval. + +Only the finalizer writes the handoff. The host re-verifies it before calling the registered local-mascot installer. It accepts a single static canvas contribution without executable services, hooks or permissions, checks project containment and package assets, and retains the previous registered package under project revisions when updating the same project's plugin. An unknown unregistered destination is preserved and reported rather than overwritten. Do not copy files into the host plugin directory or edit its registry as an installation shortcut. + +`ready_for_host` is a request, not proof that the runtime loaded the build. Distinguish the requested build, installed build, registration and observed runtime ID. A successful install alone does not prove drag/resize/menu/zoom behavior. + +## Recovering an installation failure + +Settings > Mascots shows the failure stage and short root cause, with **Open full log** and **Retry installation**. `installation-error.log` preserves the full command error, stdout and stderr/traceback. A corrected handoff is checked even after the project becomes `failed`; an unchanged rejected request is not retried every three seconds. Explicit retry also supports transient failures without restarting CanvasTTY or manually rewriting its project record. A failed update preserves the previous registered version. + +The typed host API exposes `mascots.list()`, `retry(projectId)` and `openLog(projectId)` to the trusted renderer. Plugins do not receive this privileged API. The host records installation state; the actual iframe's current runtime build must be observed separately. + +## Validation + +From the repository root, using a Python environment with Pillow and NumPy: + +```text +npm run typecheck +node --test tests/mascot-installation.test.mjs tests/plugin-bounds.test.mjs tests/plugin-manager.test.mjs tests/settings-normalizer.test.mjs tests/agent-runtime-provider-launch.test.mjs tests/terminal-launch.test.mjs +python -B assets/mascot-pipeline/tests/smoke_pipeline.py +python -B assets/mascot-pipeline/tests/placement_regression.py +``` + +CI runs the Python pipeline checks on Ubuntu and macOS separately from the application's existing checks. The Ubuntu application job also builds Linux installers, verifies the bundled mascot workflow and player, and smoke-tests the packaged AppImage. The macOS job verifies the same resources inside the packaged application before checking CLI resolution, terminal rendering and browser input. Synthetic tests verify contracts and source-to-atlas placement, not generative quality. Test the complete live creation and card interaction flow on each supported platform before release; a Windows preview run does not establish macOS or Linux UI acceptance. diff --git a/docs/metrics-and-telemetry.md b/docs/metrics-and-telemetry.md index 62012b94..4ac23e34 100644 --- a/docs/metrics-and-telemetry.md +++ b/docs/metrics-and-telemetry.md @@ -27,7 +27,28 @@ A subscription percentage cannot be derived from session tokens. Token counts do CanvasTTY never parses a provider's terminal UI to recover limits. Unsupported subscription types remain explicitly unavailable. -Per-session token accounting is **not implemented as a public CanvasTTY API yet**. The following model is a safe extension pattern, not a claim about current telemetry. +## Usage history + +Open **Usage history** to select a report period and view measured quota changes, +conditional estimates by application → profile → session, and local token counters. +The report period is not the quota window; primary and weekly windows remain separate. + +- Codex/Claude quota observations are collected every minute while CanvasTTY runs. +- Local Codex, Claude Code and Hermes accounting stores are read-only; Hermes requires + a SQLite CLI with `-safe` support on `PATH`. Missing sources are reported, not counted as zero. +- History and cursors are saved atomically in `usage-history.json` under the normal + Electron `userData` directory, bounded to 30 days and 500,000 events. +- The automatic estimate splits a comparable interval's measured change by session token + weight **if local logged activity explains that change**. This is not the provider's + billing formula or proof of account attribution. External activity remains unknown. +- Incomplete collection, immature events, resets, account changes and gaps are shown + explicitly. Earlier account percentages cannot be reconstructed from token totals. +- Only accounting metadata is exposed to the main renderer; prompts, responses and + credentials are not part of the history. Plugins do not receive this history API. + +The session-token API below remains a proposed extension contract. The built-in history +uses [`UsageHistory`](../src/shared/usageHistory.ts), not that proposed public API. +Its deterministic Electron check is `node scripts/smoke-usage-ui-packaged.mjs --app `. ## Source priority diff --git a/docs/metrics-and-telemetry.ru.md b/docs/metrics-and-telemetry.ru.md index b5f8065f..2a1a2f53 100644 --- a/docs/metrics-and-telemetry.ru.md +++ b/docs/metrics-and-telemetry.ru.md @@ -27,7 +27,29 @@ CanvasTTY рассматривает телеметрию сначала как CanvasTTY никогда не разбирает терминальный UI провайдера, чтобы восстановить лимиты. Неподдерживаемые типы подписки остаются явно недоступными. -Учёт токенов по отдельным сессиям **пока не реализован как публичный API CanvasTTY**. Модель ниже — безопасный шаблон расширения, а не утверждение о текущей телеметрии. +## История использования + +Откройте **«История использования»**, чтобы выбрать период и увидеть измеренные изменения +квоты, условные оценки по приложению → профилю → сессии и локальные счётчики токенов. +Период отчёта не меняет окно квоты: основное и недельное окна показываются отдельно. + +- Квоты Codex/Claude опрашиваются раз в минуту, пока работает CanvasTTY. +- Локальные журналы Codex, Claude Code и базы Hermes доступны только для чтения. + Для Hermes нужен SQLite CLI с поддержкой `-safe` в `PATH`; отсутствие источника не означает нулевой расход. +- История и курсоры атомарно сохраняются в `usage-history.json` в обычном каталоге + Electron `userData`; пределы — 30 дней и 500 000 событий. +- Автоматическая оценка распределяет измеренное изменение сопоставимого интервала по + весам токенов сессий, **если это изменение объясняется локальной активностью**. + Это не формула биллинга провайдера и не доказательство списания с конкретного аккаунта. + Вклад других устройств и неучтённых источников остаётся неизвестным. +- Неполный сбор, ожидающие учёта события, сбросы, смены аккаунтов и пропуски обозначаются + явно. Прошлые проценты квоты нельзя восстановить из суммы токенов. +- В главный renderer передаются только метаданные учёта, без запросов, ответов и секретов. + Плагинам API этой истории не предоставляется. + +Описанный ниже API токенов остаётся предлагаемым контрактом расширения. Встроенная история +использует [`UsageHistory`](../src/shared/usageHistory.ts), а не этот публичный контракт. +Проверка в Electron: `node scripts/smoke-usage-ui-packaged.mjs --app `. ## Приоритет источников diff --git a/docs/metrics-and-telemetry.zh-CN.md b/docs/metrics-and-telemetry.zh-CN.md index c21d44a2..06431e79 100644 --- a/docs/metrics-and-telemetry.zh-CN.md +++ b/docs/metrics-and-telemetry.zh-CN.md @@ -27,7 +27,25 @@ CanvasTTY 把遥测首先看作真实性问题,其次才是可视化问题。 CanvasTTY 绝不会通过解析服务商的终端 UI 来还原限额。不支持的订阅类型会明确保持 unavailable。 -按会话统计 token 的功能**尚未作为公开的 CanvasTTY API 实现**。以下模型是安全的扩展模式,并不代表当前已有这类遥测。 +## 用量历史 + +打开 **Usage history**,选择报告时段,查看配额实测变化、按应用 → 配置 → 会话 +分组的条件估算,以及本地 token 计数。报告时段不是配额窗口;主窗口与周窗口分别展示。 + +- CanvasTTY 运行期间,每分钟采集一次 Codex/Claude 配额观察值。 +- Codex、Claude Code 的本地日志及 Hermes 数据库仅以只读方式访问。 + Hermes 需要 `PATH` 中支持 `-safe` 的 SQLite CLI;缺少来源会明确报告,不记为零。 +- 历史与读取游标一起原子保存至 Electron 标准 `userData` 目录中的 + `usage-history.json`,最多保留 30 天、500,000 条事件。 +- 自动估算按会话 token 权重分配可比较区间的实测变化,条件是**本地记录的活动解释了该变化**。 + 这不是服务商的计费公式,也不能证明费用来自某个账户。外部活动的贡献仍未知。 +- 未完成的采集、尚未稳定的事件、重置、账户切换和观察缺口会明确标注。 + 不能根据 token 总数还原过去的配额百分比。 +- 只有记账元数据传给主渲染进程,历史中不包含提示词、回答或凭据;插件不能访问此历史 API。 + +下面的会话 token API 仍是建议的扩展契约。内置历史使用 +[`UsageHistory`](../src/shared/usageHistory.ts),而不是该公开契约。 +Electron 验证命令:`node scripts/smoke-usage-ui-packaged.mjs --app `。 ## 数据源优先级 diff --git a/electron-builder.yml b/electron-builder.yml index 318448a5..ab72403e 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -23,8 +23,10 @@ files: # node-pty loads build/Release (rebuilt for Electron by npmRebuild) and falls # back to prebuilds; its C++ and winpty sources, build scripts and header-only # node-addon-api are compile-time only. (A platform-level `files` list would - # replace this one, so foreign-OS prebuilds stay.) + # replace this one, so other platforms' prebuilds stay.) Darwin must use the + # patched source build: a fallback to the pristine npm prebuild leaks PTYs. - "!**/node_modules/node-pty/{src,deps,scripts,node-addon-api}/**" + - "!**/node_modules/node-pty/prebuilds/darwin-*/**" - "!**/node_modules/node-addon-api/**" # Browser builds that Node never resolves (yaml's "node" export is dist/, # bluebird's main is js/release). @@ -71,6 +73,12 @@ electronFuses: enableNodeCliInspectArguments: false enableEmbeddedAsarIntegrityValidation: true extraResources: + - from: assets/mascot-pipeline + to: mascot-pipeline + filter: + - "**/*" + - "!**/__pycache__/**" + - "!**/*.pyc" - from: scripts/canvastty-control.mjs to: agent-control/canvastty-control.mjs - from: agent/orchestrator @@ -113,6 +121,9 @@ electronLanguages: - zh_CN - zh-CN npmRebuild: true +# Rebuild the tracked node-pty source patch for the target Electron/architecture; +# downloading a pristine prebuild here would silently restore the macOS leak. +buildDependenciesFromSource: true compression: normal artifactName: ${productName}-${version}-${os}-${arch}.${ext} diff --git a/package-lock.json b/package-lock.json index 301d8211..61bc5948 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,13 @@ "dependencies": { "@xterm/headless": "6.0.0", "electron-updater": "6.8.9", + "hast-util-to-jsx-runtime": "2.3.6", + "lowlight": "3.3.0", "node-pty": "1.1.0", + "react-markdown": "10.1.0", + "rehype-highlight": "7.0.2", + "rehype-sanitize": "6.0.0", + "remark-gfm": "4.0.1", "secure-remote-password": "0.3.1", "unzipper": "^0.12.3", "yaml": "2.9.0" @@ -31,10 +37,11 @@ "@xterm/addon-web-links": "0.12.0", "@xterm/addon-webgl": "0.19.0", "@xterm/xterm": "6.0.0", - "electron": "43.2.0", + "electron": "43.7.7", "electron-builder": "26.15.3", "electron-vite": "5.0.0", "node-gyp": "12.4.0", + "patch-package": "8.0.1", "react": "19.2.8", "react-dom": "19.2.8", "typescript": "7.0.2", @@ -54,1339 +61,1184 @@ "vite": "8.2.2" } }, - "integrations/even-g2/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], + "integrations/even-g2/node_modules/@evenrealities/evenhub-cli": { + "version": "0.1.14", + "resolved": "https://registry.npmjs.org/@evenrealities/evenhub-cli/-/evenhub-cli-0.1.14.tgz", + "integrity": "sha512-aSaCuj01LDaRUinXu1uAAw6743oGGB6Um8mEiXB5M0mj3dbqzdJ9dijSM1lGcuDX/+wFTa/qUdbx7QippBlDuQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "peer": true, - "engines": { - "node": ">=18" + "dependencies": { + "chalk": "^5.6.2", + "commander": "^14.0.2", + "inquirer": "^13.1.0", + "js-yaml": "^4.1.1", + "open": "^11.0.0", + "qr-image": "^3.2.0", + "qrcode-terminal": "^0.12.0", + "zod": "^4.3.2" + }, + "bin": { + "eh": "main.js", + "evenhub": "main.js" + }, + "peerDependencies": { + "typescript": "^5" } }, - "integrations/even-g2/node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], + "integrations/even-g2/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, "engines": { - "node": ">=18" + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "integrations/even-g2/node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], + "integrations/even-g2/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", - "optional": true, - "os": [ - "android" - ], - "peer": true, "engines": { - "node": ">=18" + "node": ">=20" } }, - "integrations/even-g2/node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], + "integrations/even-g2/node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true, - "engines": { - "node": ">=18" - } - }, - "integrations/even-g2/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], - "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, "engines": { - "node": ">=18" + "node": "^10 || ^12 || >=14" } }, - "integrations/even-g2/node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], + "integrations/even-g2/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "Apache-2.0", "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, "engines": { - "node": ">=18" + "node": ">=14.17" } }, - "integrations/even-g2/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], + "integrations/even-g2/node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "integrations/even-g2/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "integrations/even-g2/node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "integrations/even-g2/node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=18" - } + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } }, - "integrations/even-g2/node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "integrations/even-g2/node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "integrations/even-g2/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "integrations/even-g2/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "integrations/even-g2/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" } }, - "integrations/even-g2/node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "integrations/even-g2/node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", - "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "integrations/even-g2/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "integrations/even-g2/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "peer": true, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "integrations/even-g2/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "integrations/even-g2/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true, + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, "engines": { - "node": ">=18" + "node": ">=6.0.0" } }, - "integrations/even-g2/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "integrations/even-g2/node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "integrations/even-g2/node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" } }, - "integrations/even-g2/node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "integrations/even-g2/node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, "engines": { - "node": ">=18" + "node": ">=6.9.0" } }, - "integrations/even-g2/node_modules/@evenrealities/evenhub-cli": { - "version": "0.1.14", - "resolved": "https://registry.npmjs.org/@evenrealities/evenhub-cli/-/evenhub-cli-0.1.14.tgz", - "integrity": "sha512-aSaCuj01LDaRUinXu1uAAw6743oGGB6Um8mEiXB5M0mj3dbqzdJ9dijSM1lGcuDX/+wFTa/qUdbx7QippBlDuQ==", + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, + "license": "MIT", "dependencies": { - "chalk": "^5.6.2", - "commander": "^14.0.2", - "inquirer": "^13.1.0", - "js-yaml": "^4.1.1", - "open": "^11.0.0", - "qr-image": "^3.2.0", - "qrcode-terminal": "^0.12.0", - "zod": "^4.3.2" - }, - "bin": { - "eh": "main.js", - "evenhub": "main.js" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, - "peerDependencies": { - "typescript": "^5" + "engines": { + "node": ">=6.9.0" } }, - "integrations/even-g2/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", + "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" + "node": ">=22.12.0" + } + }, + "node_modules/@electron/asar": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^5.0.0", + "glob": "^7.1.6", + "minimatch": "^3.0.4" }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "bin": { + "asar": "bin/asar.js" + }, + "engines": { + "node": ">=10.12.0" } }, - "integrations/even-g2/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "node_modules/@electron/asar/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/asar/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@electron/asar/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, "engines": { - "node": ">=20" + "node": "*" } }, - "integrations/even-g2/node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "node_modules/@electron/fuses": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", + "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "peer": true, + "dependencies": { + "chalk": "^4.1.1", + "fs-extra": "^9.0.1", + "minimist": "^1.2.5" + }, "bin": { - "esbuild": "bin/esbuild" + "electron-fuses": "dist/bin.js" + } + }, + "node_modules/@electron/fuses/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" + "node": ">=10" } }, - "integrations/even-g2/node_modules/postcss": { - "version": "8.5.28", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", - "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "node_modules/@electron/get": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", + "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.18", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "debug": "^4.1.1", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", + "progress": "^2.0.3", + "semver": "^7.6.3", + "sumchecker": "^3.0.1" }, "engines": { - "node": "^10 || ^12 || >=14" + "node": ">=22.12.0" + }, + "optionalDependencies": { + "undici": "^7.24.4" } }, - "integrations/even-g2/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "node_modules/@electron/get/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "Apache-2.0", - "peer": true, + "license": "ISC", "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" + "semver": "bin/semver.js" }, "engines": { - "node": ">=14.17" + "node": ">=10" } }, - "integrations/even-g2/node_modules/vite": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", - "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "node_modules/@electron/notarize": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.26", - "rolldown": "~1.2.4", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" + "debug": "^4.1.1", + "fs-extra": "^9.0.1", + "promise-retry": "^2.0.1" }, "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } + "node": ">= 10.0.0" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "node_modules/@electron/notarize/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=10" } }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "node_modules/@electron/osx-sign": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", + "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "dependencies": { + "compare-version": "^0.1.2", + "debug": "^4.3.4", + "fs-extra": "^10.0.0", + "isbinaryfile": "^4.0.8", + "minimist": "^1.2.6", + "plist": "^3.0.5" + }, + "bin": { + "electron-osx-flat": "bin/electron-osx-flat.js", + "electron-osx-sign": "bin/electron-osx-sign.js" + }, "engines": { - "node": ">=6.9.0" + "node": ">=12.0.0" } }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, "engines": { - "node": ">=6.9.0" + "node": ">= 8.0.0" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" + "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/@babel/generator": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "node_modules/@electron/rebuild": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", + "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.1.1", + "node-abi": "^4.2.0", + "node-api-version": "^0.2.1", + "node-gyp": "^12.2.0", + "read-binary-file-arch": "^1.0.6" + }, + "bin": { + "electron-rebuild": "lib/cli.js" }, "engines": { - "node": ">=6.9.0" + "node": ">=22.12.0" } }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "node_modules/@electron/universal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", + "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" + "@electron/asar": "^3.3.1", + "@malept/cross-spawn-promise": "^2.0.0", + "debug": "^4.3.1", + "dir-compare": "^4.2.0", + "fs-extra": "^11.1.1", + "minimatch": "^9.0.3", + "plist": "^3.1.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=16.4" } }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "node_modules/@electron/universal/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/universal/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", - "engines": { - "node": ">=6.9.0" + "dependencies": { + "balanced-match": "^1.0.0" } }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "node_modules/@electron/universal/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=6.9.0" + "node": ">=14.14" } }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "node_modules/@electron/universal/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" + "brace-expansion": "^2.0.2" }, "engines": { - "node": ">=6.9.0" + "node": ">=16 || 14 >=14.17" }, - "peerDependencies": { - "@babel/core": "^7.0.0" + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "node_modules/@electron/windows-sign": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", + "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "optional": true, + "peer": true, + "dependencies": { + "cross-dirname": "^0.1.0", + "debug": "^4.3.4", + "fs-extra": "^11.1.1", + "minimist": "^1.2.8", + "postject": "^1.0.0-alpha.6" + }, + "bin": { + "electron-windows-sign": "bin/electron-windows-sign.js" + }, "engines": { - "node": ">=6.9.0" + "node": ">=14.14" } }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "node_modules/@electron/windows-sign/node_modules/fs-extra": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", + "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, "engines": { - "node": ">=6.9.0" + "node": ">=14.14" } }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "aix" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.0.0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz", - "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, + "optional": true, + "os": [ + "android" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", - "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", - "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" + "node": ">=18" } }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/traverse": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6.9.0" + "node": ">=18" } }, - "node_modules/@electron-internal/extract-zip": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.5.tgz", - "integrity": "sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=22.12.0" + "node": ">=18" } }, - "node_modules/@electron/asar": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", - "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], "dev": true, "license": "MIT", - "dependencies": { - "commander": "^5.0.0", - "glob": "^7.1.6", - "minimatch": "^3.0.4" - }, - "bin": { - "asar": "bin/asar.js" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10.12.0" + "node": ">=18" } }, - "node_modules/@electron/asar/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@electron/asar/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": "*" + "node": ">=18" } }, - "node_modules/@electron/fuses": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@electron/fuses/-/fuses-1.8.0.tgz", - "integrity": "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==", + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], "dev": true, "license": "MIT", - "dependencies": { - "chalk": "^4.1.1", - "fs-extra": "^9.0.1", - "minimist": "^1.2.5" - }, - "bin": { - "electron-fuses": "dist/bin.js" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/@electron/fuses/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@electron/get": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.1.0.tgz", - "integrity": "sha512-3kSBtG8ObcTVfXanm5vVJ6UnBLEVmVsRk1M+vGqCuMBV+XLCbJYuWQful+yIy0GQDsSlK0kHEriEHn7SPk4EnA==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^3.0.0", - "graceful-fs": "^4.2.11", - "progress": "^2.0.3", - "semver": "^7.6.3", - "sumchecker": "^3.0.1" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=22.12.0" - }, - "optionalDependencies": { - "undici": "^7.24.4" + "node": ">=18" } }, - "node_modules/@electron/get/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@electron/notarize": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz", - "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "fs-extra": "^9.0.1", - "promise-retry": "^2.0.1" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">= 10.0.0" + "node": ">=18" } }, - "node_modules/@electron/notarize/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@electron/osx-sign": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.3.tgz", - "integrity": "sha512-KZ8mhXvWv2rIEgMbWZ4y33bDHyUKMXnx4M0sTyPNK/vcB81ImdeY9Ggdqy0SWbMDgmbqyQ+phgejh6V3R2QuSg==", + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "compare-version": "^0.1.2", - "debug": "^4.3.4", - "fs-extra": "^10.0.0", - "isbinaryfile": "^4.0.8", - "minimist": "^1.2.6", - "plist": "^3.0.5" - }, - "bin": { - "electron-osx-flat": "bin/electron-osx-flat.js", - "electron-osx-sign": "bin/electron-osx-sign.js" - }, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">=12.0.0" + "node": ">=18" } }, - "node_modules/@electron/osx-sign/node_modules/isbinaryfile": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz", - "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==", + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], "dev": true, "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">= 8.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/gjtorikian/" + "node": ">=18" } }, - "node_modules/@electron/rebuild": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.2.0.tgz", - "integrity": "sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@malept/cross-spawn-promise": "^2.0.0", - "debug": "^4.1.1", - "node-abi": "^4.2.0", - "node-api-version": "^0.2.1", - "node-gyp": "^12.2.0", - "read-binary-file-arch": "^1.0.6" - }, - "bin": { - "electron-rebuild": "lib/cli.js" - }, - "engines": { - "node": ">=22.12.0" - } - }, - "node_modules/@electron/universal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", - "integrity": "sha512-Wn9sPYIVFRFl5HmwMJkARCCf7rqK/EurkfQ/rJZ14mHP3iYTjZSIOSVonEAnhWeAXwtw7zOekGRlc6yTtZ0t+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@electron/asar": "^3.3.1", - "@malept/cross-spawn-promise": "^2.0.0", - "debug": "^4.3.1", - "dir-compare": "^4.2.0", - "fs-extra": "^11.1.1", - "minimatch": "^9.0.3", - "plist": "^3.1.0" - }, - "engines": { - "node": ">=16.4" - } - }, - "node_modules/@electron/universal/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/universal/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "peer": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", "optional": true, "os": [ - "aix" + "openharmony" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/android-arm": { + "node_modules/@esbuild/sunos-x64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ - "arm" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "sunos" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/android-arm64": { + "node_modules/@esbuild/win32-arm64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -1394,468 +1246,276 @@ "license": "MIT", "optional": true, "os": [ - "android" + "win32" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/android-x64": { + "node_modules/@esbuild/win32-ia32": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ - "x64" + "ia32" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "android" + "win32" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/darwin-arm64": { + "node_modules/@esbuild/win32-x64": { "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ - "arm64" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "darwin" + "win32" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@evenrealities/even_hub_sdk": { + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@evenrealities/even_hub_sdk/-/even_hub_sdk-0.0.14.tgz", + "integrity": "sha512-lNtyIldZfe0dhAVxCfNuJ7waH2lMPli8DyklA8jF4DlvByQtbK1janodPNo7M8+7Plp30mlQT8d+vYU3gO8vKQ==", "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=18" + "node": "^20.0.0 || >=22.0.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" + "node_modules/@fontsource/ibm-plex-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", + "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" + "node_modules/@fontsource/ibm-plex-sans": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", + "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], + "node_modules/@inquirer/ansi": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", + "integrity": "sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], + "node_modules/@inquirer/checkbox": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.5.tgz", + "integrity": "sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/core": "^12.0.3", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], + "node_modules/@inquirer/checkbox/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], + "node_modules/@inquirer/checkbox/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "ISC", "engines": { - "node": ">=18" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], + "node_modules/@inquirer/confirm": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.2.tgz", + "integrity": "sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/type": "4.1.1" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], + "node_modules/@inquirer/confirm/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@inquirer/ansi": "^2.0.8", + "@inquirer/figures": "^2.0.9", + "@inquirer/type": "4.1.1", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], + "node_modules/@inquirer/confirm/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "ISC", "engines": { - "node": ">=18" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], + "node_modules/@inquirer/core": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", + "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "@inquirer/ansi": "^2.0.7", + "@inquirer/figures": "^2.0.7", + "@inquirer/type": "^4.0.7", + "cli-width": "^4.1.0", + "fast-wrap-ansi": "^0.2.0", + "mute-stream": "^3.0.0", + "signal-exit": "^4.1.0" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], + "node_modules/@inquirer/core/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "ISC", "engines": { - "node": ">=18" + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], + "node_modules/@inquirer/editor": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.3.tgz", + "integrity": "sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], + "dependencies": { + "@inquirer/core": "^12.0.3", + "@inquirer/external-editor": "^3.0.5", + "@inquirer/type": "4.1.1" + }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@evenrealities/even_hub_sdk": { - "version": "0.0.14", - "resolved": "https://registry.npmjs.org/@evenrealities/even_hub_sdk/-/even_hub_sdk-0.0.14.tgz", - "integrity": "sha512-lNtyIldZfe0dhAVxCfNuJ7waH2lMPli8DyklA8jF4DlvByQtbK1janodPNo7M8+7Plp30mlQT8d+vYU3gO8vKQ==", - "license": "MIT", - "engines": { - "node": "^20.0.0 || >=22.0.0" - } - }, - "node_modules/@fontsource/ibm-plex-mono": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-mono/-/ibm-plex-mono-5.3.0.tgz", - "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@fontsource/ibm-plex-sans": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/@fontsource/ibm-plex-sans/-/ibm-plex-sans-5.3.0.tgz", - "integrity": "sha512-CbE4CbbEEZJX860XyUiRpsksXIQR8Rp2XDva2VO53NJox9tVNtusrysd2x5YkUEY3ErQ66W1IiiQL8/wihhw5w==", - "license": "OFL-1.1", - "funding": { - "url": "https://github.com/sponsors/ayuhito" - } - }, - "node_modules/@inquirer/ansi": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.8.tgz", - "integrity": "sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-5.2.5.tgz", - "integrity": "sha512-bRt8J8m+Fot9CXv+zNQGXUq2ET0MggR1fPz7v6edN6MFYmsbfGnMmkmWZJEegMKqrAC8ej/o1sqisHZXZJMAfQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/core": "^12.0.3", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/checkbox/node_modules/@inquirer/core": { - "version": "12.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", + "node_modules/@inquirer/editor/node_modules/@inquirer/core": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", + "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", "dev": true, "license": "MIT", "dependencies": { @@ -1879,7 +1539,7 @@ } } }, - "node_modules/@inquirer/checkbox/node_modules/signal-exit": { + "node_modules/@inquirer/editor/node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", @@ -1892,10 +1552,10 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@inquirer/confirm": { - "version": "6.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.3.2.tgz", - "integrity": "sha512-Xvr/0HggjddPtGppuqVmxhTw+Hr8PvsZ/k0HmOEaAqQEt80OITNkFWnsdNmyT0/eM4Ab+iJLx2R8rctlEyfSVg==", + "node_modules/@inquirer/expand": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.5.tgz", + "integrity": "sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==", "dev": true, "license": "MIT", "dependencies": { @@ -1914,7 +1574,7 @@ } } }, - "node_modules/@inquirer/confirm/node_modules/@inquirer/core": { + "node_modules/@inquirer/expand/node_modules/@inquirer/core": { "version": "12.0.3", "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", @@ -1941,7 +1601,7 @@ } } }, - "node_modules/@inquirer/confirm/node_modules/signal-exit": { + "node_modules/@inquirer/expand/node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", @@ -1954,20 +1614,15 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@inquirer/core": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", - "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", + "node_modules/@inquirer/external-editor": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.5.tgz", + "integrity": "sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" + "chardet": "^2.1.1", + "iconv-lite": "^0.7.2" }, "engines": { "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" @@ -1981,28 +1636,24 @@ } } }, - "node_modules/@inquirer/core/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "node_modules/@inquirer/figures": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", + "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", "dev": true, - "license": "ISC", + "license": "MIT", "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" } }, - "node_modules/@inquirer/editor": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-5.3.3.tgz", - "integrity": "sha512-YsKkS2q63IiLtaDK/9nqzdComN97SDQrmKiyNggN+ceP4ty+Z6VwyTz3FpjeUWeW1Efss2xHFKCC9sx7hnrsxg==", + "node_modules/@inquirer/input": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.6.tgz", + "integrity": "sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==", "dev": true, "license": "MIT", "dependencies": { "@inquirer/core": "^12.0.3", - "@inquirer/external-editor": "^3.0.5", "@inquirer/type": "4.1.1" }, "engines": { @@ -2017,7 +1668,7 @@ } } }, - "node_modules/@inquirer/editor/node_modules/@inquirer/core": { + "node_modules/@inquirer/input/node_modules/@inquirer/core": { "version": "12.0.3", "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", @@ -2044,163 +1695,7 @@ } } }, - "node_modules/@inquirer/editor/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@inquirer/expand": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-5.1.5.tgz", - "integrity": "sha512-uHuXLmXW+TtIfT/9vSBotypAkqn1n34Ul+CLGPos/xANyO4Ff5xZzkYhbKR4NEcfVK4a9mHQOpwVZzluSHFRGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/type": "4.1.1" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand/node_modules/@inquirer/core": { - "version": "12.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@inquirer/external-editor": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-3.0.5.tgz", - "integrity": "sha512-f3QQJRIX5ZEneBHNUIuPjmbdzHnmRFJA8r2dkcb8q+OM5Uv5KtnuAttQumnrjcBVBM3mcTX1CkmtAkU58VRZxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.2" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.9.tgz", - "integrity": "sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@inquirer/input": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-5.1.6.tgz", - "integrity": "sha512-HtcJhB2QFVXbLuJ5S3syhNbTUVxYvwqV4VRBDkQceBloC9bmTViUoRFP5PbSaDZb3HzfPmpuU/gG4ybVBz4FHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^12.0.3", - "@inquirer/type": "4.1.1" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/input/node_modules/@inquirer/core": { - "version": "12.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-12.0.3.tgz", - "integrity": "sha512-wsSy0sznmXwkty+2PzZwx00Cazc/E0r0B7mAzdGROz2Ct+DFZXaK7WDjGZvgjRldxH5ZhFVfF2lgkYrqgOw2KA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.8", - "@inquirer/figures": "^2.0.9", - "@inquirer/type": "4.1.1", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/input/node_modules/signal-exit": { + "node_modules/@inquirer/input/node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", @@ -3397,32 +2892,6 @@ "win32" ] }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@szmarczak/http-timer": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "dev": true, - "license": "MIT", - "dependencies": { - "defer-to-connect": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -3468,24 +2937,10 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/cacheable-request": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-cache-semantics": "*", - "@types/keyv": "^3.1.4", - "@types/node": "*", - "@types/responselike": "^1.0.0" - } - }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", - "dev": true, "license": "MIT", "dependencies": { "@types/ms": "*" @@ -3495,9 +2950,17 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/fs-extra": { "version": "9.0.13", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-9.0.13.tgz", @@ -3508,28 +2971,28 @@ "@types/node": "*" } }, - "node_modules/@types/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", - "dev": true, - "license": "MIT" + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } }, - "node_modules/@types/keyv": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "dev": true, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", "license": "MIT", "dependencies": { - "@types/node": "*" + "@types/unist": "*" } }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -3546,7 +3009,6 @@ "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3562,15 +3024,11 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/responselike": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", - "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", @@ -3827,1848 +3285,2484 @@ "node": ">=16.20.0" } }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", + "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@xterm/headless": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", + "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "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": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/app-builder-lib": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", + "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@electron/asar": "3.4.1", + "@electron/fuses": "^1.8.0", + "@electron/get": "^3.0.0", + "@electron/notarize": "2.5.0", + "@electron/osx-sign": "1.3.3", + "@electron/rebuild": "^4.0.4", + "@electron/universal": "2.0.3", + "@malept/flatpak-bundler": "^0.4.0", + "@noble/hashes": "^2.2.0", + "@peculiar/webcrypto": "^1.7.1", + "@types/fs-extra": "9.0.13", + "ajv": "^8.18.0", + "asn1js": "^3.0.10", + "async-exit-hook": "^2.0.1", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chromium-pickle-js": "^0.2.0", + "ci-info": "4.3.1", + "debug": "^4.3.4", + "dotenv": "^16.4.5", + "dotenv-expand": "^11.0.6", + "ejs": "^3.1.8", + "electron-publish": "26.15.3", + "fs-extra": "^10.1.0", + "hosted-git-info": "^4.1.0", + "isbinaryfile": "^5.0.0", + "jiti": "^2.4.2", + "js-yaml": "^4.1.0", + "json5": "^2.2.3", + "lazy-val": "^1.0.5", + "minimatch": "^10.2.5", + "pkijs": "^3.4.0", + "plist": "3.1.0", + "proper-lockfile": "^4.1.2", + "resedit": "^1.7.0", + "semver": "~7.7.3", + "tar": "^7.5.7", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0", + "unzipper": "^0.12.3", + "which": "^5.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "dmg-builder": "26.15.3", + "electron-builder-squirrel-windows": "26.15.3" + } + }, + "node_modules/app-builder-lib/node_modules/ci-info": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/app-builder-lib/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/app-builder-lib/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/app-builder-lib/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-buffer-to-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-buffer-to-hex/-/array-buffer-to-hex-1.0.0.tgz", + "integrity": "sha512-arycdkxgK1cj6s03GDb96tlCxOl1n3kg9M2OHseUc6Pqyqp+lgfceFPmG507eI5V+oxOSEnlOw/dFc7LXBXF4Q==", + "license": "MIT" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-exit-hook": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", + "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/builder-util": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", + "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.6", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "cross-spawn": "^7.0.6", + "debug": "^4.3.4", + "fs-extra": "^10.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "js-yaml": "^4.1.0", + "sanitize-filename": "^1.6.3", + "source-map-support": "^0.5.19", + "stat-mode": "^1.0.0", + "temp-file": "^3.4.0", + "tiny-async-pool": "1.3.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvastty-even-g2": { + "resolved": "integrations/even-g2", + "link": true + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", + "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], + "license": "MIT" + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16.20.0" + "node": ">=18" } }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], + "node_modules/chromium-pickle-js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", + "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" + "license": "MIT" + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } ], + "license": "MIT", "engines": { - "node": ">=16.20.0" + "node": ">=8" } }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], + "license": "ISC", "engines": { - "node": ">=16.20.0" + "node": ">= 12" } }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, "engines": { - "node": ">=16.20.0" + "node": ">=12" } }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, "engines": { - "node": ">=16.20.0" + "node": ">=7.0.0" } }, - "node_modules/@vitejs/plugin-react": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", - "integrity": "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==", + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" + "delayed-stream": "~1.0.0" }, "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + "node": ">= 0.8" } }, - "node_modules/@xmldom/xmldom": { - "version": "0.8.15", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", - "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", "dev": true, "license": "MIT", "engines": { - "node": ">=10.0.0" + "node": ">= 6" } }, - "node_modules/@xterm/addon-fit": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", - "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "node_modules/compare-version": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", + "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } }, - "node_modules/@xterm/addon-search": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", - "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, - "node_modules/@xterm/addon-web-links": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", - "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, - "node_modules/@xterm/addon-webgl": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", - "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", - "dev": true, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "license": "MIT" }, - "node_modules/@xterm/headless": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.0.0.tgz", - "integrity": "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw==", - "license": "MIT", - "workspaces": [ - "addons/*" - ] - }, - "node_modules/@xterm/xterm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", - "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "node_modules/cross-dirname": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", + "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "workspaces": [ - "addons/*" - ] + "optional": true, + "peer": true }, - "node_modules/abbrev": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", - "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, - "license": "ISC", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">= 8" + } + }, + "node_modules/crypto-digest-sync": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/crypto-digest-sync/-/crypto-digest-sync-1.0.0.tgz", + "integrity": "sha512-UQBOB5z+HF4iA8shKQ3PPwhCmdFAihwcytD1Qh4uiz78x04cZZmKtZ1F1VyAjkrA8uEZqXt2tMXfj3dJHtcbng==", + "license": "MIT" + }, + "node_modules/crypto-random-hex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-hex/-/crypto-random-hex-1.0.0.tgz", + "integrity": "sha512-1DuZQ03El13TRgfrqbbjW40Gvi4OKInny/Wxqj23/JMXe214C/3Tlz92bKXWDW3NZT5RjXUGdYW4qiIOUPf+cA==", + "license": "MIT", + "dependencies": { + "array-buffer-to-hex": "^1.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, "engines": { - "node": ">= 14" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "character-entities": "^2.0.0" }, "funding": { "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ansi-regex": { - "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": ">=8" + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" }, "engines": { - "node": ">=8" + "node": ">=18" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/app-builder-lib": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", - "integrity": "sha512-2VnyWkqsP5v5XbBhL3tD5Syx8iNPBYsoU7kY4S2fz7wg8Rj/nztWKCUzGKaFRTv0Xwf3/H058CR1Kvtd/3lRow==", + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", "dev": true, "license": "MIT", - "dependencies": { - "@electron/asar": "3.4.1", - "@electron/fuses": "^1.8.0", - "@electron/get": "^3.0.0", - "@electron/notarize": "2.5.0", - "@electron/osx-sign": "1.3.3", - "@electron/rebuild": "^4.0.4", - "@electron/universal": "2.0.3", - "@malept/flatpak-bundler": "^0.4.0", - "@noble/hashes": "^2.2.0", - "@peculiar/webcrypto": "^1.7.1", - "@types/fs-extra": "9.0.13", - "ajv": "^8.18.0", - "asn1js": "^3.0.10", - "async-exit-hook": "^2.0.1", - "builder-util": "26.15.3", - "builder-util-runtime": "9.7.0", - "chromium-pickle-js": "^0.2.0", - "ci-info": "4.3.1", - "debug": "^4.3.4", - "dotenv": "^16.4.5", - "dotenv-expand": "^11.0.6", - "ejs": "^3.1.8", - "electron-publish": "26.15.3", - "fs-extra": "^10.1.0", - "hosted-git-info": "^4.1.0", - "isbinaryfile": "^5.0.0", - "jiti": "^2.4.2", - "js-yaml": "^4.1.0", - "json5": "^2.2.3", - "lazy-val": "^1.0.5", - "minimatch": "^10.2.5", - "pkijs": "^3.4.0", - "plist": "3.1.0", - "proper-lockfile": "^4.1.2", - "resedit": "^1.7.0", - "semver": "~7.7.3", - "tar": "^7.5.7", - "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0", - "unzipper": "^0.12.3", - "which": "^5.0.0" - }, "engines": { - "node": ">=14.0.0" + "node": ">=18" }, - "peerDependencies": { - "dmg-builder": "26.15.3", - "electron-builder-squirrel-windows": "26.15.3" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/app-builder-lib/node_modules/@electron/get": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", - "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" }, "engines": { - "node": ">=14" + "node": ">= 0.4" }, - "optionalDependencies": { - "global-agent": "^3.0.0" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", "dev": true, "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/app-builder-lib/node_modules/ci-info": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", - "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], "license": "MIT", "engines": { - "node": ">=8" + "node": ">=0.4.0" } }, - "node_modules/app-builder-lib/node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/app-builder-lib/node_modules/isexe": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", - "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/app-builder-lib/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/app-builder-lib/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, + "license": "Apache-2.0", "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/app-builder-lib/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/app-builder-lib/node_modules/which": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", - "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", - "dev": true, - "license": "ISC", "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" + "dequal": "^2.0.0" }, - "engines": { - "node": "^18.17.0 || >=20.5.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/array-buffer-to-hex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-to-hex/-/array-buffer-to-hex-1.0.0.tgz", - "integrity": "sha512-arycdkxgK1cj6s03GDb96tlCxOl1n3kg9M2OHseUc6Pqyqp+lgfceFPmG507eI5V+oxOSEnlOw/dFc7LXBXF4Q==", - "license": "MIT" - }, - "node_modules/asn1js": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", - "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "node_modules/dir-compare": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", + "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", "dev": true, - "license": "BSD-3-Clause", + "license": "MIT", "dependencies": { - "pvtsutils": "^1.3.6", - "pvutils": "^1.1.5", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=12.0.0" + "minimatch": "^3.0.5", + "p-limit": "^3.1.0 " } }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "node_modules/dir-compare/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, - "node_modules/async-exit-hook": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/async-exit-hook/-/async-exit-hook-2.0.1.tgz", - "integrity": "sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==", + "node_modules/dir-compare/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/dir-compare/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, "engines": { - "node": ">=0.12.0" + "node": "*" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "node_modules/dmg-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", + "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0" + } }, - "node_modules/at-least-node": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", - "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "dev": true, - "license": "ISC", + "license": "BSD-2-Clause", "engines": { - "node": ">= 4.0.0" + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, - "node_modules/aws4": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", - "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "node_modules/dotenv-expand": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", + "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", "dev": true, - "license": "MIT" + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, "engines": { - "node": "18 || 20 || >=22" + "node": ">= 0.4" } }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.12", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", - "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, "bin": { - "baseline-browser-mapping": "dist/cli.cjs" + "ejs": "bin/cli.js" }, "engines": { - "node": ">=6.0.0" + "node": ">=0.10.0" } }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", - "license": "MIT" - }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "node_modules/electron": { + "version": "43.7.7", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.7.7.tgz", + "integrity": "sha512-y0pg+KhWLAkW2Ts2njkFFNFl3KtB1FmbUp9Te9i+R2Og2v1fcI/I6MADJ7jvnzB5UKdt+U+0EUoCnVL79xMPMA==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^4.0.2" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" + }, + "bin": { + "electron": "cli.js", + "install-electron": "install.js" }, "engines": { - "node": "20 || >=22" + "node": ">= 22.12.0" } }, - "node_modules/browserslist": { - "version": "4.28.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", - "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "node_modules/electron-builder": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", + "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.44", - "caniuse-lite": "^1.0.30001806", - "electron-to-chromium": "^1.5.393", - "node-releases": "^2.0.51", - "update-browserslist-db": "^1.2.3" + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "dmg-builder": "26.15.3", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "simple-update-notifier": "2.0.0", + "yargs": "^17.6.2" }, "bin": { - "browserslist": "cli.js" + "electron-builder": "cli.js", + "install-app-deps": "install-app-deps.js" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": ">=14.0.0" } }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "node_modules/electron-builder-squirrel-windows": { + "version": "26.15.3", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", + "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true, + "dependencies": { + "app-builder-lib": "26.15.3", + "builder-util": "26.15.3", + "electron-winstaller": "5.4.0" + } }, - "node_modules/builder-util": { + "node_modules/electron-publish": { "version": "26.15.3", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", - "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", + "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/fs-extra": "^9.0.11", + "aws4": "^1.13.2", + "builder-util": "26.15.3", + "builder-util-runtime": "9.7.0", + "chalk": "^4.1.2", + "form-data": "^4.0.5", + "fs-extra": "^10.1.0", + "lazy-val": "^1.0.5", + "mime": "^2.5.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.400", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.400.tgz", + "integrity": "sha512-96EWDNjM59SYflgeV5Ylsf4EMiq1a25YjCnJH7cxn/AF2H3pILRweaUnoLax0yKHWdpOzY6JKEu45e8irqZIHA==", "dev": true, + "license": "ISC" + }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", "license": "MIT", "dependencies": { - "@types/debug": "^4.1.6", "builder-util-runtime": "9.7.0", - "chalk": "^4.1.2", - "cross-spawn": "^7.0.6", - "debug": "^4.3.4", "fs-extra": "^10.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.0", "js-yaml": "^4.1.0", - "sanitize-filename": "^1.6.3", - "source-map-support": "^0.5.19", - "stat-mode": "^1.0.0", - "temp-file": "^3.4.0", - "tiny-async-pool": "1.3.0" - }, - "engines": { - "node": ">=14.0.0" + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" } }, - "node_modules/builder-util-runtime": { - "version": "9.7.0", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", - "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", - "license": "MIT", - "dependencies": { - "debug": "^4.3.4", - "sax": "^1.2.4" + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { - "node": ">=12.0.0" + "node": ">=10" } }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "node_modules/electron-vite": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-5.0.0.tgz", + "integrity": "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==", "dev": true, "license": "MIT", "dependencies": { - "run-applescript": "^7.0.0" + "@babel/core": "^7.28.4", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "cac": "^6.7.14", + "esbuild": "^0.25.11", + "magic-string": "^0.30.19", + "picocolors": "^1.1.1" }, - "engines": { - "node": ">=18" + "bin": { + "electron-vite": "bin/electron-vite.js" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytestreamjs": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", - "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", - "dev": true, - "license": "BSD-3-Clause", "engines": { - "node": ">=6.0.0" + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@swc/core": "^1.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + } } }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "node_modules/electron-winstaller": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", + "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "peer": true, + "dependencies": { + "@electron/asar": "^3.2.1", + "debug": "^4.1.1", + "fs-extra": "^7.0.1", + "lodash": "^4.17.21", + "temp": "^0.9.0" + }, "engines": { - "node": ">=8" + "node": ">=8.0.0" + }, + "optionalDependencies": { + "@electron/windows-sign": "^1.1.2" } }, - "node_modules/cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "node_modules/electron-winstaller/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, "engines": { - "node": ">=10.6.0" + "node": ">=6 <7 || >=8" } }, - "node_modules/cacheable-request": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "node_modules/electron-winstaller/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", - "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=8" + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "node_modules/electron-winstaller/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, + "peer": true, "engines": { - "node": ">= 0.4" + "node": ">= 4.0.0" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001806", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", - "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/canvastty-even-g2": { - "resolved": "integrations/even-g2", - "link": true - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/electron/node_modules/@types/node": { + "version": "24.13.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", + "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "undici-types": "~7.18.0" } }, - "node_modules/chardet": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz", - "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==", + "node_modules/electron/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "dev": true, "license": "MIT" }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } + "license": "MIT" }, - "node_modules/chromium-pickle-js": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/chromium-pickle-js/-/chromium-pickle-js-0.2.0.tgz", - "integrity": "sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==", - "dev": true, + "node_modules/encode-utf8": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/encode-utf8/-/encode-utf8-1.0.3.tgz", + "integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==", "license": "MIT" }, - "node_modules/ci-info": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], "license": "MIT", "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } + "license": "MIT" }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=12" + "node": ">= 0.4" } }, - "node_modules/clone-response": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, "license": "MIT", - "dependencies": { - "mimic-response": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">= 0.4" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "dev": true, "license": "MIT", "dependencies": { - "color-name": "~1.1.4" + "es-errors": "^1.3.0" }, "engines": { - "node": ">=7.0.0" + "node": ">= 0.4" } }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", "dependencies": { - "delayed-stream": "~1.0.0" + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.4" } }, - "node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, + "hasInstallScript": true, "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { - "node": ">= 6" + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, - "node_modules/compare-version": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/compare-version/-/compare-version-0.1.2.tgz", - "integrity": "sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==", + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">=6" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "dev": true, - "license": "MIT" + "license": "Apache-2.0" }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/crypto-digest-sync": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/crypto-digest-sync/-/crypto-digest-sync-1.0.0.tgz", - "integrity": "sha512-UQBOB5z+HF4iA8shKQ3PPwhCmdFAihwcytD1Qh4uiz78x04cZZmKtZ1F1VyAjkrA8uEZqXt2tMXfj3dJHtcbng==", "license": "MIT" }, - "node_modules/crypto-random-hex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-hex/-/crypto-random-hex-1.0.0.tgz", - "integrity": "sha512-1DuZQ03El13TRgfrqbbjW40Gvi4OKInny/Wxqj23/JMXe214C/3Tlz92bKXWDW3NZT5RjXUGdYW4qiIOUPf+cA==", + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, "license": "MIT", "dependencies": { - "array-buffer-to-hex": "^1.0.0" + "fast-string-truncated-width": "^3.0.2" } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" } - } + ], + "license": "BSD-3-Clause" }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", "dev": true, "license": "MIT", "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "fast-string-width": "^3.0.2" } }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/default-browser": { - "version": "5.5.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", - "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "minimatch": "^5.0.1" } }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "dependencies": { + "balanced-match": "^1.0.0" } }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, - "license": "MIT", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, "engines": { "node": ">=10" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" + "to-regex-range": "^5.0.1" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=8" } }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" } }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 6" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, "engines": { - "node": ">=0.4.0" + "node": ">=12" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } + "license": "ISC" }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, + "hasInstallScript": true, "license": "MIT", - "optional": true + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } }, - "node_modules/dir-compare": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz", - "integrity": "sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==", + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", - "dependencies": { - "minimatch": "^3.0.5", - "p-limit": "^3.1.0 " + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/dir-compare/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "engines": { + "node": ">=6.9.0" } }, - "node_modules/dir-compare/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, "engines": { - "node": "*" + "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/dmg-builder": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.3.tgz", - "integrity": "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ==", + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", - "fs-extra": "^10.1.0", - "js-yaml": "^4.1.0" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dotenv-expand": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-11.0.7.tgz", - "integrity": "sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dotenv": "^16.4.5" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { - "node": ">=12" + "node": ">= 0.4" }, "funding": { - "url": "https://dotenvx.com" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/dunder-proto": { + "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" } }, - "node_modules/duplexer2": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", - "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", - "license": "BSD-3-Clause", - "dependencies": { - "readable-stream": "^2.0.2" - } - }, - "node_modules/ejs": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", - "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, - "license": "Apache-2.0", + "license": "ISC", "dependencies": { - "jake": "^10.8.5" - }, - "bin": { - "ejs": "bin/cli.js" + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": ">=0.10.0" + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/electron": { - "version": "43.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-43.2.0.tgz", - "integrity": "sha512-80zvrgG7ZRXD+tD0IyLvrnN9n+veSxadMRsMaC9wKKP3iUbtC7rGM8+dVuCmOb0Rrwwv8ESW4awnUZh9Hbp1fA==", + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { - "@electron-internal/extract-zip": "^1.0.1", - "@electron/get": "^5.0.0", - "@types/node": "^24.9.0" - }, - "bin": { - "electron": "cli.js", - "install-electron": "install.js" - }, - "engines": { - "node": ">= 22.12.0" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/electron-builder": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.3.tgz", - "integrity": "sha512-a1KM5heqS3gQCZzizXEI8RjJy3QVogULPdeSknt76uLDpBIW/HDGsMg/XgP0riP6PI9COsRvFITKKGDqA8fJxA==", + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", - "builder-util-runtime": "9.7.0", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "dmg-builder": "26.15.3", - "fs-extra": "^10.1.0", - "lazy-val": "^1.0.5", - "simple-update-notifier": "2.0.0", - "yargs": "^17.6.2" - }, - "bin": { - "electron-builder": "cli.js", - "install-app-deps": "install-app-deps.js" + "brace-expansion": "^1.1.7" }, "engines": { - "node": ">=14.0.0" + "node": "*" } }, - "node_modules/electron-builder-squirrel-windows": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz", - "integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==", + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", - "peer": true, - "dependencies": { - "app-builder-lib": "26.15.3", - "builder-util": "26.15.3", - "electron-winstaller": "5.4.0" + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/electron-publish": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", - "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", - "dependencies": { - "@types/fs-extra": "^9.0.11", - "aws4": "^1.13.2", - "builder-util": "26.15.3", - "builder-util-runtime": "9.7.0", - "chalk": "^4.1.2", - "form-data": "^4.0.5", - "fs-extra": "^10.1.0", - "lazy-val": "^1.0.5", - "mime": "^2.5.2" + "engines": { + "node": ">=8" } }, - "node_modules/electron-to-chromium": { - "version": "1.5.400", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.400.tgz", - "integrity": "sha512-96EWDNjM59SYflgeV5Ylsf4EMiq1a25YjCnJH7cxn/AF2H3pILRweaUnoLax0yKHWdpOzY6JKEu45e8irqZIHA==", + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, - "license": "ISC" - }, - "node_modules/electron-updater": { - "version": "6.8.9", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", - "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", "license": "MIT", "dependencies": { - "builder-util-runtime": "9.7.0", - "fs-extra": "^10.1.0", - "js-yaml": "^4.1.0", - "lazy-val": "^1.0.5", - "lodash.escaperegexp": "^4.1.2", - "lodash.isequal": "^4.5.0", - "semver": "~7.7.3", - "tiny-typed-emitter": "^2.1.0" - } - }, - "node_modules/electron-updater/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "es-define-property": "^1.0.0" }, - "engines": { - "node": ">=10" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/electron-vite": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/electron-vite/-/electron-vite-5.0.0.tgz", - "integrity": "sha512-OHp/vjdlubNlhNkPkL/+3JD34ii5ov7M0GpuXEVdQeqdQ3ulvVR7Dg/rNBLfS5XPIFwgoBLDf9sjjrL+CuDyRQ==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/core": "^7.28.4", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "cac": "^6.7.14", - "esbuild": "^0.25.11", - "magic-string": "^0.30.19", - "picocolors": "^1.1.1" - }, - "bin": { - "electron-vite": "bin/electron-vite.js" - }, "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "@swc/core": "^1.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" + "node": ">= 0.4" }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/electron-winstaller": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz", - "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { - "@electron/asar": "^3.2.1", - "debug": "^4.1.1", - "fs-extra": "^7.0.1", - "lodash": "^4.17.21", - "temp": "^0.9.0" + "has-symbols": "^1.0.3" }, "engines": { - "node": ">=8.0.0" + "node": ">= 0.4" }, - "optionalDependencies": { - "@electron/windows-sign": "^1.1.2" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/electron-winstaller/node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" + "function-bind": "^1.1.2" }, "engines": { - "node": ">=6 <7 || >=8" + "node": ">= 0.4" } }, - "node_modules/electron-winstaller/node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", "license": "MIT", - "peer": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/electron-winstaller/node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", "license": "MIT", - "peer": true, - "engines": { - "node": ">= 4.0.0" + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/electron/node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "dev": true, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/electron/node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, + "node_modules/hex-to-array-buffer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hex-to-array-buffer/-/hex-to-array-buffer-1.1.0.tgz", + "integrity": "sha512-vvl3IM8FfT1uOnHtEqyjkDK9Luqz6MQrH82qIvVnjyXxRhkeaEZyRRPiBgf2yym3nweRVEfayxt/1SoTXZYd4Q==", "license": "MIT" }, - "node_modules/encode-utf8": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/encode-utf8/-/encode-utf8-1.0.3.tgz", - "integrity": "sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==", - "license": "MIT" + "node_modules/highlight.js": { + "version": "11.11.2", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz", + "integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "node_modules/hosted-git-info": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", + "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "once": "^1.4.0" + "lru-cache": "^6.0.0" + }, + "engines": { + "node": ">=10" } }, - "node_modules/env-paths": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", - "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=10" } }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "node_modules/hosted-git-info/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, - "license": "MIT" + "license": "ISC" }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", "license": "MIT", - "engines": { - "node": ">= 0.4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, "engines": { - "node": ">= 0.4" + "node": ">= 14" } }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0" + "agent-base": "^7.1.2", + "debug": "4" }, "engines": { - "node": ">= 0.4" + "node": ">= 14" } }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "dev": true, "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, - "license": "MIT", - "optional": true + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/inquirer": { + "version": "13.4.3", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-13.4.3.tgz", + "integrity": "sha512-EPd3IqieHSavSOXh+LZhrIkdQcOELWeRblLT6kslQr+cF9XTh/HxZdSt1YkHH1iq4dvqBnV42uwg2YlorgOy6g==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "@inquirer/ansi": "^2.0.5", + "@inquirer/core": "^11.1.10", + "@inquirer/prompts": "^8.4.3", + "@inquirer/type": "^4.0.5", + "mute-stream": "^3.0.0", + "run-async": "^4.0.6", + "rxjs": "^7.8.2" }, "engines": { - "node": ">=18" + "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", "license": "MIT", - "engines": { - "node": ">=6" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", "license": "MIT", - "optional": true, - "engines": { - "node": ">=10" + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-truncated-width": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-width": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "dev": true, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", "license": "MIT", - "dependencies": { - "fast-string-truncated-width": "^3.0.2" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, - "node_modules/fast-wrap-ansi": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", - "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, "license": "MIT", - "dependencies": { - "fast-string-width": "^3.0.2" + "engines": { + "node": ">=8" } }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", "dev": true, "license": "MIT", "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "node": ">=20" }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/filelist": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", - "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "minimatch": "^5.0.1" + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/filelist/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" + "engines": { + "node": ">=0.12.0" } }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", - "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", "dev": true, "license": "MIT", "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" + "is-inside-container": "^1.0.0" }, "engines": { - "node": ">= 6" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/fs-extra": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", - "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/isbinaryfile": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", + "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", + "dev": true, "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, "engines": { - "node": ">=12" + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=10" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" + "bin": { + "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "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/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", - "engines": { - "node": ">=6.9.0" + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" } }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "node_modules/jsbn": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, - "license": "ISC", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, "engines": { - "node": "6.* || 8.* || >= 10.*" + "node": ">=6" } }, - "node_modules/get-intrinsic": { + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" }, "engines": { "node": ">= 0.4" @@ -5677,973 +5771,1303 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "node_modules/json-stable-stringify/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" + "bin": { + "json5": "lib/cli.js" }, "engines": { - "node": ">= 0.4" + "node": ">=6" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", "license": "MIT", "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" + "universalify": "^2.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "optionalDependencies": { + "graceful-fs": "^4.1.6" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, + "license": "Public Domain", "funding": { - "url": "https://github.com/sponsors/isaacs" + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", "dev": true, "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "graceful-fs": "^4.1.11" } }, - "node_modules/glob/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "node_modules/lazy-val": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", + "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, - "license": "ISC", + "license": "MPL-2.0", "dependencies": { - "brace-expansion": "^1.1.7" + "detect-libc": "^2.0.3" }, "engines": { - "node": "*" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, - "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "BSD-3-Clause", + "license": "MPL-2.0", "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, + "os": [ + "android" + ], "engines": { - "node": ">=10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/global-agent/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "ISC", + "license": "MPL-2.0", "optional": true, - "bin": { - "semver": "bin/semver.js" - }, + "os": [ + "darwin" + ], "engines": { - "node": ">=10" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, + "os": [ + "darwin" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/got": { - "version": "11.8.6", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.2", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=10.19.0" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "license": "ISC" + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=8" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", "optional": true, - "dependencies": { - "es-define-property": "^1.0.0" + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.4" + "node": ">= 12.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } + "license": "MIT" }, - "node_modules/hex-to-array-buffer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/hex-to-array-buffer/-/hex-to-array-buffer-1.1.0.tgz", - "integrity": "sha512-vvl3IM8FfT1uOnHtEqyjkDK9Luqz6MQrH82qIvVnjyXxRhkeaEZyRRPiBgf2yym3nweRVEfayxt/1SoTXZYd4Q==", + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", "license": "MIT" }, - "node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "dev": true, - "license": "ISC", + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", "dependencies": { - "lru-cache": "^6.0.0" + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" }, - "engines": { - "node": ">=10" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/hosted-git-info/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" + "yallist": "^3.0.2" } }, - "node_modules/hosted-git-info/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, - "node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", "license": "MIT", - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" - }, - "engines": { - "node": ">=10.19.0" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, "engines": { - "node": ">= 14" + "node": ">= 0.4" } }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "dev": true, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.3.tgz", + "integrity": "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" + "url": "https://opencollective.com/unified" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/inquirer": { - "version": "13.4.3", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-13.4.3.tgz", - "integrity": "sha512-EPd3IqieHSavSOXh+LZhrIkdQcOELWeRblLT6kslQr+cF9XTh/HxZdSt1YkHH1iq4dvqBnV42uwg2YlorgOy6g==", - "dev": true, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.5", - "@inquirer/core": "^11.1.10", - "@inquirer/prompts": "^8.4.3", - "@inquirer/type": "^4.0.5", - "mute-stream": "^3.0.0", - "run-async": "^4.0.6", - "rxjs": "^7.8.2" - }, "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^21.7.0 || ^20.12.0" + "node": ">=12" }, - "peerDependencies": { - "@types/node": ">=18" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.1.0.tgz", + "integrity": "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/is-in-ssh": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", - "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", - "dev": true, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", "license": "MIT", - "engines": { - "node": ">=20" + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.1.tgz", + "integrity": "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==", "license": "MIT", "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", - "dev": true, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", "license": "MIT", "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/isbinaryfile": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz", - "integrity": "sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==", - "dev": true, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", "license": "MIT", - "engines": { - "node": ">= 18.0.0" + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, "funding": { - "url": "https://github.com/sponsors/gjtorikian/" + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/jake": { - "version": "10.9.4", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", - "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", - "dev": true, - "license": "Apache-2.0", + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", "dependencies": { - "async": "^3.2.6", - "filelist": "^1.0.4", - "picocolors": "^1.1.1" + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" }, - "bin": { - "jake": "bin/cli.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" }, - "engines": { - "node": ">=10" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "dev": true, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "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/js-yaml": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", - "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/jsbn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" }, - "engines": { - "node": ">=6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "license": "ISC", - "optional": true - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "node_modules/mdast-util-to-markdown": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.2.0.tgz", + "integrity": "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" }, - "engines": { - "node": ">=6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", "license": "MIT", "dependencies": { - "universalify": "^2.0.0" + "@types/mdast": "^4.0.0" }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" } }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, + "node_modules/micromark": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", + "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz", + "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "json-buffer": "3.0.1" + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.1.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-edit-map": "^1.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.3" } }, - "node_modules/lazy-val": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz", - "integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==", - "license": "MIT" - }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" + "url": "https://opencollective.com/unified" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/unified" } }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/unified" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/unified" } }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz", + "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/unified" } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/unified" } }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/unified" } }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" + "node_modules/micromark-factory-space": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz", + "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-edit-map": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz", + "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT" }, - "node_modules/lodash.escaperegexp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", - "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT" }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", - "license": "MIT" + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } }, - "node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", - "engines": { - "node": ">=8" + "dependencies": { + "micromark-util-types": "^2.0.0" } }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", "dependencies": { - "yallist": "^3.0.2" + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" } }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" } }, - "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz", + "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "escape-string-regexp": "^4.0.0" + "braces": "^3.0.3", + "picomatch": "^2.3.1" }, "engines": { - "node": ">=10" + "node": ">=8.6" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/mime": { @@ -6682,16 +7106,6 @@ "node": ">= 0.6" } }, - "node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", @@ -6904,9 +7318,9 @@ } }, "node_modules/node-gyp/node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", "dev": true, "license": "MIT", "engines": { @@ -6971,26 +7385,12 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">= 0.4" } @@ -7026,38 +7426,158 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pad-start": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pad-start/-/pad-start-1.0.2.tgz", + "integrity": "sha512-EBN8Ez1SVRcZT1XsIE4WkdnZ5coLoaChkIgAET6gIlaLhXqCz9upVk0DQWFtOYkrpTVvbEppRUnqhTiJrBdkfw==", + "license": "MIT" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "node_modules/patch-package/node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-package/node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/patch-package/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-package/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" + "license": "ISC", + "bin": { + "semver": "bin/semver.js" }, "engines": { "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/pad-start": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/pad-start/-/pad-start-1.0.2.tgz", - "integrity": "sha512-EBN8Ez1SVRcZT1XsIE4WkdnZ5coLoaChkIgAET6gIlaLhXqCz9upVk0DQWFtOYkrpTVvbEppRUnqhTiJrBdkfw==", - "license": "MIT" - }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -7283,15 +7803,14 @@ "signal-exit": "^3.0.2" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", "license": "MIT", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" } }, "node_modules/pvtsutils": { @@ -7330,24 +7849,10 @@ "qrcode-terminal": "bin/qrcode-terminal.js" } }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -7366,6 +7871,33 @@ "react": "^19.2.8" } }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-refresh": { "version": "0.18.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", @@ -7404,6 +7936,103 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/rehype-highlight": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7442,26 +8071,6 @@ "url": "https://github.com/sponsors/jet2jet" } }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/responselike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lowercase-keys": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -7487,25 +8096,6 @@ "rimraf": "bin.js" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/rolldown": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", @@ -7690,29 +8280,22 @@ "semver": "bin/semver.js" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", - "optional": true, "dependencies": { - "type-fest": "^0.13.1" + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">= 0.4" } }, "node_modules/shebang-command": { @@ -7771,6 +8354,16 @@ "node": ">=10" } }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7802,13 +8395,15 @@ "source-map": "^0.6.0" } }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } }, "node_modules/stat-mode": { "version": "1.0.0", @@ -7844,6 +8439,20 @@ "node": ">=8" } }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -7857,6 +8466,24 @@ "node": ">=8" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -7999,6 +8626,39 @@ "tmp": "^0.2.0" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/truncate-utf8-bytes": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz", @@ -8016,20 +8676,6 @@ "dev": true, "license": "0BSD" }, - "node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -8066,9 +8712,9 @@ } }, "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.30.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz", + "integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==", "dev": true, "license": "MIT", "optional": true, @@ -8083,6 +8729,107 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/universalify": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", @@ -8163,6 +8910,34 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -8900,6 +9675,16 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 29a2321b..a4f92082 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "test:windows-pipe-host": "node scripts/build-windows-agent-pipe-host.mjs --require-windows --self-test", "test": "node --test --test-concurrency=1 --test-reporter=tap tests/*.test.mjs", "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", - "postinstall": "install-electron && electron-rebuild -f -w node-pty && node scripts/build-windows-agent-pipe-host.mjs", + "postinstall": "install-electron && patch-package --error-on-fail && electron-rebuild -f -w node-pty --build-from-source && node scripts/build-windows-agent-pipe-host.mjs", "build:even": "npm run build --workspace integrations/even-g2", "build:mobile": "tsc --noEmit -p integrations/mobile/tsconfig.json && npm run build --prefix integrations/mobile", "test:even": "npm test --workspace integrations/even-g2", @@ -51,7 +51,13 @@ "dependencies": { "@xterm/headless": "6.0.0", "electron-updater": "6.8.9", + "hast-util-to-jsx-runtime": "2.3.6", + "lowlight": "3.3.0", "node-pty": "1.1.0", + "react-markdown": "10.1.0", + "rehype-highlight": "7.0.2", + "rehype-sanitize": "6.0.0", + "remark-gfm": "4.0.1", "secure-remote-password": "0.3.1", "unzipper": "^0.12.3", "yaml": "2.9.0" @@ -67,10 +73,11 @@ "@xterm/addon-web-links": "0.12.0", "@xterm/addon-webgl": "0.19.0", "@xterm/xterm": "6.0.0", - "electron": "43.2.0", + "electron": "43.7.7", "electron-builder": "26.15.3", "electron-vite": "5.0.0", "node-gyp": "12.4.0", + "patch-package": "8.0.1", "react": "19.2.8", "react-dom": "19.2.8", "typescript": "7.0.2", diff --git a/patches/node-pty+1.1.0.patch b/patches/node-pty+1.1.0.patch new file mode 100644 index 00000000..d1d117f7 --- /dev/null +++ b/patches/node-pty+1.1.0.patch @@ -0,0 +1,221 @@ +diff --git a/node_modules/node-pty/src/unix/pty.cc b/node_modules/node-pty/src/unix/pty.cc +index 7b4b9e1..557fdae 100644 +--- a/node_modules/node-pty/src/unix/pty.cc ++++ b/node_modules/node-pty/src/unix/pty.cc +@@ -167,6 +167,9 @@ void SetupExitCallback(Napi::Env env, Napi::Function cb, pid_t pid) { + } + } + } ++ // The exit watcher owns this kqueue, independently of the returned PTY. ++ if (kq >= 0) ++ close(kq); + #else + while (true) { + errno = 0; +@@ -370,9 +373,19 @@ Napi::Value PtyFork(const Napi::CallbackInfo& info) { + int err = -1; + pty_posix_spawn(argv, env, term, &winp, &master, &pid, &err); + if (err != 0) { +- throw Napi::Error::New(napiEnv, "posix_spawnp failed."); ++ Napi::Error error = Napi::Error::New(napiEnv, "posix_spawnp failed."); ++ error.Value().Set("errno", Napi::Number::New(napiEnv, err)); ++ throw error; + } + if (pty_nonblock(master) == -1) { ++ // No exit callback has been registered yet: close and reap only the child ++ // created by this call before throwing away its native ownership. ++ close(master); ++ if (pid > 0) { ++ kill(pid, SIGKILL); ++ int status; ++ HANDLE_EINTR(waitpid(pid, &status, 0)); ++ } + throw Napi::Error::New(napiEnv, "Could not set master fd to nonblocking."); + } + #else +@@ -485,10 +498,14 @@ Napi::Value PtyOpen(const Napi::CallbackInfo& info) { + } + + if (pty_nonblock(master) == -1) { ++ close(master); ++ close(slave); + throw Napi::Error::New(env, "Could not set master fd to nonblocking."); + } + + if (pty_nonblock(slave) == -1) { ++ close(master); ++ close(slave); + throw Napi::Error::New(env, "Could not set slave fd to nonblocking."); + } + +@@ -691,96 +708,92 @@ pty_posix_spawn(char** argv, char** env, + int* master, + pid_t* pid, + int* err) { +- int low_fds[3]; +- size_t count = 0; +- +- for (; count < 3; count++) { +- low_fds[count] = posix_openpt(O_RDWR); +- if (low_fds[count] >= STDERR_FILENO) +- break; +- } ++ // Backport the ownership fixes from node-pty #882. Scope ownership also ++ // covers failed reservations and partially initialized spawn objects; no ++ // counter can skip low_fds[0] or address a nonexistent low_fds[3]. ++ struct ScopedFd { ++ int value = -1; ++ ~ScopedFd() { if (value >= 0) close(value); } ++ int release() { int fd = value; value = -1; return fd; } ++ }; ++ struct FileActions { ++ posix_spawn_file_actions_t value{}; ++ bool initialized = false; ++ ~FileActions() { if (initialized) posix_spawn_file_actions_destroy(&value); } ++ }; ++ struct SpawnAttributes { ++ posix_spawnattr_t value{}; ++ bool initialized = false; ++ ~SpawnAttributes() { if (initialized) posix_spawnattr_destroy(&value); } ++ }; ++ ++ *master = -1; ++ *pid = -1; ++ *err = 0; ++ ScopedFd low_fds[3]; ++ for (auto& fd : low_fds) { ++ fd.value = posix_openpt(O_RDWR); ++ if (fd.value == -1) { *err = errno; return; } ++ if (fd.value >= STDERR_FILENO) break; ++ } ++ ++ ScopedFd owned_master; ++ owned_master.value = posix_openpt(O_RDWR); ++ if (owned_master.value == -1) { *err = errno; return; } ++ if (grantpt(owned_master.value) == -1) { *err = errno; return; } ++ if (unlockpt(owned_master.value) == -1) { *err = errno; return; } + ++ // Use TIOCPTYGNAME instead of ptsname() to avoid threading problems. ++ char slave_pty_name[128]; ++ if (ioctl(owned_master.value, TIOCPTYGNAME, slave_pty_name) == -1) { *err = errno; return; } ++ ScopedFd slave; ++ slave.value = open(slave_pty_name, O_RDWR | O_NOCTTY); ++ if (slave.value == -1) { *err = errno; return; } ++ if (termp && tcsetattr(slave.value, TCSANOW, termp) == -1) { *err = errno; return; } ++ if (winp && ioctl(slave.value, TIOCSWINSZ, winp) == -1) { *err = errno; return; } ++ ++ FileActions acts; ++ *err = posix_spawn_file_actions_init(&acts.value); ++ if (*err != 0) return; ++ acts.initialized = true; ++ for (int fd = STDIN_FILENO; fd <= STDERR_FILENO; ++fd) { ++ *err = posix_spawn_file_actions_adddup2(&acts.value, slave.value, fd); ++ if (*err != 0) return; ++ } ++ const int child_closes[] = { slave.value, owned_master.value }; ++ for (int fd : child_closes) { ++ *err = posix_spawn_file_actions_addclose(&acts.value, fd); ++ if (*err != 0) return; ++ } ++ ++ SpawnAttributes attrs; ++ *err = posix_spawnattr_init(&attrs.value); ++ if (*err != 0) return; ++ attrs.initialized = true; + int flags = POSIX_SPAWN_CLOEXEC_DEFAULT | + POSIX_SPAWN_SETSIGDEF | + POSIX_SPAWN_SETSIGMASK | + POSIX_SPAWN_SETSID; +- *master = posix_openpt(O_RDWR); +- if (*master == -1) { +- return; +- } +- +- int res = grantpt(*master) || unlockpt(*master); +- if (res == -1) { +- return; +- } +- +- // Use TIOCPTYGNAME instead of ptsname() to avoid threading problems. +- int slave; +- char slave_pty_name[128]; +- res = ioctl(*master, TIOCPTYGNAME, slave_pty_name); +- if (res == -1) { +- return; +- } +- +- slave = open(slave_pty_name, O_RDWR | O_NOCTTY); +- if (slave == -1) { +- return; +- } +- +- if (termp) { +- res = tcsetattr(slave, TCSANOW, termp); +- if (res == -1) { +- return; +- }; +- } +- +- if (winp) { +- res = ioctl(slave, TIOCSWINSZ, winp); +- if (res == -1) { +- return; +- } +- } +- +- posix_spawn_file_actions_t acts; +- posix_spawn_file_actions_init(&acts); +- posix_spawn_file_actions_adddup2(&acts, slave, STDIN_FILENO); +- posix_spawn_file_actions_adddup2(&acts, slave, STDOUT_FILENO); +- posix_spawn_file_actions_adddup2(&acts, slave, STDERR_FILENO); +- posix_spawn_file_actions_addclose(&acts, slave); +- posix_spawn_file_actions_addclose(&acts, *master); +- +- posix_spawnattr_t attrs; +- posix_spawnattr_init(&attrs); +- *err = posix_spawnattr_setflags(&attrs, flags); +- if (*err != 0) { +- goto done; +- } +- ++ *err = posix_spawnattr_setflags(&attrs.value, flags); ++ if (*err != 0) return; + sigset_t signal_set; +- /* Reset all signal the child to their default behavior */ + sigfillset(&signal_set); +- *err = posix_spawnattr_setsigdefault(&attrs, &signal_set); +- if (*err != 0) { +- goto done; +- } +- +- /* Reset the signal mask for all signals */ ++ *err = posix_spawnattr_setsigdefault(&attrs.value, &signal_set); ++ if (*err != 0) return; + sigemptyset(&signal_set); +- *err = posix_spawnattr_setsigmask(&attrs, &signal_set); +- if (*err != 0) { +- goto done; +- } ++ *err = posix_spawnattr_setsigmask(&attrs.value, &signal_set); ++ if (*err != 0) return; + ++ pid_t spawned_pid = -1; + do +- *err = posix_spawn(pid, argv[0], &acts, &attrs, argv, env); ++ *err = posix_spawn(&spawned_pid, argv[0], &acts.value, &attrs.value, argv, env); + while (*err == EINTR); +-done: +- posix_spawn_file_actions_destroy(&acts); +- posix_spawnattr_destroy(&attrs); +- +- for (; count > 0; count--) { +- close(low_fds[count]); ++ if (*err == 0) { ++ *master = owned_master.release(); ++ *pid = spawned_pid; + } ++ // Everything except the successfully transferred master is owned here. ++ // Child file-actions close descriptors in the child, NOT in this parent. + } + #endif + diff --git a/scripts/audit-pty-test-bundle.mjs b/scripts/audit-pty-test-bundle.mjs new file mode 100644 index 00000000..5d26cbeb --- /dev/null +++ b/scripts/audit-pty-test-bundle.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { lstat, readFile, readdir, realpath } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import asar from "@electron/asar"; +import { SECRET_PATTERNS } from "./audit-secrets.mjs"; + +const ARCHIVE_ROOTS = new Set(["out", "node_modules", "package.json", "LICENSE"]); +const PRIVATE_SEGMENTS = new Set([".git", ".codex", ".agents", ".runtime", ".pty-results", "logs"]); +const PRIVATE_FILES = /^(?:\.env(?:\.|$)|(?:auth|credentials?|settings|terminal-sessions|controller|connection)\.json$)|\.(?:pem|key|p12|pfx)$/iu; +const publicSpeechPaths = JSON.parse(await readFile(new URL("./fixtures/public-speech-source-path-hashes.json", import.meta.url), "utf8")); +const PUBLIC_SOURCE_PATH_HASHES = new Map(Object.entries(publicSpeechPaths.files).map(([path, hashes]) => [path, new Set(hashes)])); + +function pathIssues(name) { + const parts = name.replaceAll("\\", "/").split("/"); + const issues = []; + if (parts.some((part) => PRIVATE_SEGMENTS.has(part))) issues.push("private state directory"); + if (PRIVATE_FILES.test(parts.at(-1)) && parts.at(-1) !== ".env.example") issues.push("sensitive filename"); + if (parts.some((part) => part === "prebuilds") && parts.some((part) => /^darwin-/u.test(part))) { + issues.push("unpatched Darwin prebuild"); + } + return issues; +} + +export function textIssues(content, publicSourcePathHashes = new Set()) { + // These exact NUL-terminated __FILE__ strings were already public in the + // checksum-pinned vendor archive. Do not exempt a username, directory, + // arbitrary CI path, different file, or any other credential rule. + const homeContent = publicSourcePathHashes.size ? content.replace(/\/(?:Users|home)\/[^\0\r\n]+(?=\0)/gu, (path) => { + const digest = createHash("sha256").update(path).digest("hex"); + return publicSourcePathHashes.has(digest) ? "" : path; + }) : content; + const hits = []; + for (const [name, pattern] of SECRET_PATTERNS) { + pattern.lastIndex = 0; + if (pattern.test(name === "personal home path" ? homeContent : content)) hits.push(name); + pattern.lastIndex = 0; + } + return hits; +} + +/** Scan application payload, not the separately downloaded stock Chromium engine. */ +export async function auditPtyTestBundle(application) { + const root = await realpath(application); + const installedRoots = ["/Applications", join(homedir(), "Applications")]; + assert.ok(!installedRoots.some((directory) => root === directory || root.startsWith(`${directory}/`)), + "refusing to audit an installed application rather than the separate test build"); + const resources = join(root, "Contents", "Resources"); + const archive = join(resources, "app.asar"); + assert.ok((await lstat(archive)).isFile(), "the actual packaged app.asar must exist"); + const issues = []; + let filesScanned = 0; + const record = (path, rules) => { for (const rule of new Set(rules)) issues.push({ path, rule }); }; + + for (const raw of asar.listPackage(archive, { isPack: false })) { + const name = raw.replace(/^[/\\]+/u, "").replaceAll("\\", "/"); + assert.ok(name && !name.split("/").includes(".."), "unsafe ASAR entry"); + const metadata = asar.statFile(archive, name, false); + record(`app.asar/${name}`, pathIssues(name)); + if (!ARCHIVE_ROOTS.has(name.split("/")[0])) record(`app.asar/${name}`, ["unexpected archive root"]); + if (metadata.link !== undefined) { + record(`app.asar/${name}`, ["archive symlink requires review"]); + continue; + } + if (metadata.files || metadata.unpacked) continue; + assert.ok(Number.isSafeInteger(metadata.size) && metadata.size <= 32 * 1024 * 1024, + `oversized application archive entry requires review: ${name}`); + record(`app.asar/${name}`, textIssues(asar.extractFile(archive, name).toString("utf8"))); + filesScanned++; + } + + async function walk(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + const label = relative(root, path).replaceAll("\\", "/"); + if (path === archive) continue; + record(label, pathIssues(label)); + if (entry.isSymbolicLink()) { + const target = await realpath(path); + if (!target.startsWith(`${root}/`)) record(label, ["bundle symlink escapes application"]); + // Do not follow links or read a file outside the owned test bundle. + continue; + } + if (entry.isDirectory()) await walk(path); + else if (entry.isFile()) { + // Includes unpacked addons, extraResources and native helper payload. + // Stream large model assets; never silently skip a file due to its size. + const hits = new Set(); + const publicHashes = PUBLIC_SOURCE_PATH_HASHES.get(label); + if (publicHashes) { + // Read the four small reviewed libraries whole: a block boundary + // must not split a proven-public source string before its NUL. + assert.ok((await lstat(path)).size <= 32 * 1024 * 1024, "reviewed vendor library unexpectedly large"); + for (const rule of textIssues((await readFile(path)).toString("utf8"), publicHashes)) hits.add(rule); + } else { + let carry = ""; + for await (const bytes of createReadStream(path, { highWaterMark: 1024 * 1024 })) { + const text = carry + bytes.toString("utf8"); + for (const rule of textIssues(text)) hits.add(rule); + carry = text.slice(-8192); + } + } + record(label, [...hits]); + filesScanned++; + } + } + } + await walk(resources); + // The owner's update-helper is application payload too. The stock Electron + // executable/frameworks are verified by package/signature checks, not by a + // secret regex that would flag built-in cryptographic parser constants. + const helper = join(root, "Contents", "MacOS", "canvastty-update-helper"); + if (await lstat(helper).then((item) => item.isFile(), () => false)) { + let content = ""; + for await (const bytes of createReadStream(helper)) { + content = content.slice(-8192) + bytes.toString("utf8"); + record("Contents/MacOS/canvastty-update-helper", textIssues(content)); + } + filesScanned++; + } + assert.ok(filesScanned > 0, "an empty scan cannot pass"); + return { filesScanned, issues }; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + assert.equal(process.argv.length, 3, "usage: node scripts/audit-pty-test-bundle.mjs /path/to/test.app"); + const result = await auditPtyTestBundle(process.argv[2]); + if (result.issues.length) { + // Rules and relative paths only; never print a matched credential value. + for (const issue of result.issues) console.error(`${issue.path}: ${issue.rule}`); + process.exitCode = 1; + } else console.log(`Test bundle payload audit passed: ${result.filesScanned} files, no high-confidence secrets, private state, unreviewed home paths or Darwin prebuild fallback; exact public vendor source-path hashes are documented separately.`); +} diff --git a/scripts/audit-secrets.mjs b/scripts/audit-secrets.mjs index 8e0bbe04..6323263f 100644 --- a/scripts/audit-secrets.mjs +++ b/scripts/audit-secrets.mjs @@ -13,7 +13,9 @@ const IGNORED_ENTRY_NAMES = new Set([ "out", "dist", "release", - "artifacts" + "artifacts", + "__pycache__", + ".mascot-venv" ]); // Generated native build output, gitignored exactly like out/ and dist/. node-gyp // writes the builder's absolute home path into the generated project files and diff --git a/scripts/fixtures/public-speech-source-path-hashes.json b/scripts/fixtures/public-speech-source-path-hashes.json new file mode 100644 index 00000000..925e1f7e --- /dev/null +++ b/scripts/fixtures/public-speech-source-path-hashes.json @@ -0,0 +1,37 @@ +{ + "source": "https://github.com/handy-computer/transcribe.cpp/releases/download/v0.2.3/transcribe-native-0.2.3-macos-arm64-metal.tar.gz", + "sha256": "1cc5e89d442f55c165a3f90e49090cec75cec349071d834c0aa656161afa9543", + "files": { + "Contents/Resources/companion/speech/libggml-base.dylib": [ + "079980bb492b3a2c88682d89ac3c0e29147715b7ac0e752a63b4a623c38a1072", + "205a5f8dbb08b4191a2c76591ee173d8603e09153c27c842db9c9e39961f0ad3", + "3fb8ac559fb2b5f42f21dc96addf21d39121953efbab63e9aca0947360539edf", + "74ab16c52939453c80aee0605f80d5a8b16b7c949a518657365e88a6e0849f25", + "8010dd609c8ce23b5acb2f93d285e31112c8430c7dfb419c3aeca4aa478be936", + "8afad15c0c35e25ff4ff6143a767a30d769ec06e7930246986b97e75ee719e1f", + "abc1e980912bf9f52d321b21c9fbe00971ff132523bf7d6c2fc4c541f1083132", + "ce3b81d1d1c2b30d5ce2744c248cbf4e671745b50f2f35f61e6a8e6e9608ee9a", + "e90f77e1714c788c5d830c3891d9eb62363fe909994460a2cba15af2f7cbf518" + ], + "Contents/Resources/companion/speech/libggml-cpu.dylib": [ + "6da1e9805b8ff1961910e72a961ba6c9b5b4879fcc8f74d16ef05cb83725f103", + "adfc5cb091dd904a0d0b21c20132160e99cea0f4af4f18f8761059969de47f8c", + "6f7192af94486557e4c6a8aa3805489a1f5ffe77f68057316365ccaadf436f4a", + "91b19e06b4f573d1423617ae97e2d60d1258e06efe27a4f9035ae97d12247abb", + "9d00488a353522963864efd6d79466dbb752ebcea3edbd91592028d0b7d59790", + "ca415da52f3a95b953f3d69e41a5e6d1590a684090b6a9e131f1a988d147265a", + "f1a8fc96ad5c217ad1144f3bf72944fa205abdaeb5e45effe5e56f1f41dc9b4c" + ], + "Contents/Resources/companion/speech/libggml-metal.dylib": [ + "64068d18771c2a4f560610b761ad97a21933113dd226c1991cfa672adb197721", + "9062c67e3b330735e54a72f2b2a92f3bc99e8307032db34ac1dd189fba24a2a5", + "a1f74b1dc11db84017250efa601043399d0345751a99382b18921e3a3a8b4c21", + "aa158335a6c706e1e06feab76ba3480ab20fe6767c18e2bc3f6811c51d87886f", + "b41e3d201e535cb775fd1216ca91a3b3f6380d26772535ef7ba3b6b4784ab8f5", + "e16592ce309f726e493df9ca1e705d7574c3ce7ed08be21c260bf2e453ba65a5" + ], + "Contents/Resources/companion/speech/libggml.dylib": [ + "9ef6e581c6958385e10f7e118f15a21744df7be9d71147b778e8db9680a634c9" + ] + } +} diff --git a/scripts/smoke-pty-resources.mjs b/scripts/smoke-pty-resources.mjs new file mode 100644 index 00000000..69a3d2dd --- /dev/null +++ b/scripts/smoke-pty-resources.mjs @@ -0,0 +1,301 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { closeSync, constants, accessSync, fstatSync, openSync, readFileSync, writeSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { constants as osConstants, tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; + +// The kernel PTY pool is shared by all processes. Never run a stress test on a +// person's desktop, including a second Electron instance with a clean profile. +if (process.platform !== "darwin" || process.env.CI !== "true" || + process.env.GITHUB_ACTIONS !== "true" || process.env.RUNNER_ENVIRONMENT !== "github-hosted") { + throw new Error("PTY resource smoke requires an isolated macOS CI runner; no PTY was allocated."); +} + +const require = createRequire(import.meta.url); +const runtimeStdioReuse = new Map(); +const options = parseArguments(process.argv.slice(2)); +if (options.worker) await runWorker(options); +else await runDriver(options); + +function parseArguments(args) { + const result = { cycles: 400, stdioMask: 0, worker: false, expectLeak: false }; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--worker") result.worker = true; + else if (arg === "--expect-leak") result.expectLeak = true; + else if (["--cycles", "--stdio-mask", "--app", "--report", "--worker-report"].includes(arg)) { + const value = args[++i]; + if (value === undefined || value.startsWith("--")) throw new Error(`Missing value for ${arg}`); + if (arg === "--cycles" || arg === "--stdio-mask") { + if (!/^\d+$/u.test(value)) throw new Error(`Invalid integer for ${arg}`); + result[arg === "--cycles" ? "cycles" : "stdioMask"] = Number(value); + } else result[arg.slice(2)] = resolve(value); + } else throw new Error(`Unknown argument: ${arg}`); + } + assert.ok(result.cycles >= 1 && result.cycles <= 500, "cycles must be between 1 and 500"); + assert.ok(result.stdioMask >= 0 && result.stdioMask <= 7, "stdio mask must be between 0 and 7"); + if (result.app?.startsWith("/Applications/")) throw new Error("Refusing to test an installed application."); + if (result.expectLeak && (result.cycles > 6 || result.stdioMask !== 0 || result.app)) { + throw new Error("The unpatched control is restricted to at most six cycles of the source dependency."); + } + return result; +} + +async function runDriver(config) { + if (config.app) { + config.app = await realpath(config.app); + if (config.app.startsWith("/Applications/") || /\/Users\/[^/]+\/Applications\//u.test(config.app)) { + throw new Error("Refusing a canonical path to an installed application."); + } + } + const root = await mkdtemp(join(tmpdir(), "canvastty-pty-resources-")); + try { + const reportPath = join(root, "result.json"); + const executable = config.app ? join(config.app, "Contents", "MacOS", "CanvasTTY") : require("electron"); + const args = [fileURLToPath(import.meta.url), "--worker", "--cycles", String(config.cycles), + "--stdio-mask", String(config.stdioMask), "--worker-report", reportPath]; + if (config.app) args.push("--app", config.app); + if (config.expectLeak) args.push("--expect-leak"); + const child = spawn(executable, args, { + cwd: process.cwd(), + env: { PATH: "/usr/bin:/bin", HOME: root, TMPDIR: root, CI: "true", GITHUB_ACTIONS: "true", + RUNNER_ENVIRONMENT: "github-hosted", ELECTRON_RUN_AS_NODE: "1" }, + stdio: "ignore" + }); + const exited = new Promise((resolveExit, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolveExit({ code, signal })); + }); + let timedOut = false; + let timeout; + const exit = await Promise.race([exited, new Promise((resolveTimeout) => { + timeout = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + resolveTimeout({ code: null, signal: "SIGKILL" }); + }, 600_000); + })]).catch((error) => ({ code: 1, signal: null, error: error.message })) + .finally(() => clearTimeout(timeout)); + if (timedOut) await Promise.race([exited, delay(2000)]); + const report = await readFile(reportPath, "utf8").then(JSON.parse, () => ({ + ok: false, runtime: config.app ? "packaged" : "source", measurementsUnavailable: true, + error: timedOut ? "Driver watchdog expired; its own worker was stopped." : + (exit.error || "Worker exited without a measurement report.") + })); + if (timedOut) { report.ok = false; report.timedOut = true; } + if (config.report) { + await mkdir(dirname(config.report), { recursive: true }); + await writeFile(config.report, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 }); + } + console.log(`CANVASTTY_PTY_FD_REPORT ${JSON.stringify(report)}`); + assert.equal(exit.signal, null, "the smoke must complete normally"); + assert.equal(exit.code, 0, "the worker reported a regression"); + assert.equal(report.ok, true, "the native resource checks failed"); + } finally { + // Only the directory created by this invocation, never an application profile. + await rm(root, { recursive: true, force: true }); + } +} + +function census() { + const text = execFileSync("/usr/sbin/lsof", ["-nP", "-p", String(process.pid), "-F", "fnt"], { + encoding: "utf8", timeout: 10_000, maxBuffer: 8 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] + }); + const entries = []; + let entry; + for (const line of text.split("\n")) { + if (/^f\d+$/u.test(line)) { entry = {}; entries.push(entry); } + else if (line.startsWith("f")) entry = undefined; + else if (entry && line.startsWith("n")) entry.name = line.slice(1); + else if (entry && line.startsWith("t")) entry.type = line.slice(1); + } + assert.ok(entries.length > 0, "a failed/empty descriptor census must not become a zero count"); + return { + total: entries.length, + ptmx: entries.filter((fd) => fd.name === "/dev/ptmx").length, + slave: entries.filter((fd) => fd.name?.startsWith("/dev/ttys") || fd.name?.includes("(revoked)")).length, + kqueue: entries.filter((fd) => fd.type === "KQUEUE").length + }; +} + +function returnedToBaseline(before, after) { + // lsof's synchronous child and Node's asynchronous pipe cleanup can differ by + // two ordinary descriptors. PTY masters/slaves and exit kqueues have NO allowance. + return Math.abs(after.total - before.total) <= 2 && after.ptmx === before.ptmx && + after.slave === before.slave && after.kqueue === before.kqueue; +} + +async function settledCensus(before) { + let after; + for (let attempt = 0; attempt < 15; attempt++) { + await delay(20); + after = census(); + if (returnedToBaseline(before, after)) break; + } + return after; +} + +function verifyClosedStdio(mask) { + for (let fd = 0; fd < 3; fd++) { + if (!(mask & (1 << fd))) continue; + let metadata; + try { metadata = fstatSync(fd); } + catch (error) { if (error.code === "EBADF") continue; throw error; } + // Node/libuv may reuse a vacant stdio number for its own wakeup pipe or + // queue after the native function returns. That is not a leaked PTY. The + // pure native harness requires the original holes to remain closed; this + // integration test rejects PTY reuse and still demands stable FD counts. + let kind = metadata.isFIFO() ? "PIPE" : metadata.isSocket() ? "socket" : undefined; + if (!kind) { + const details = execFileSync("/usr/sbin/lsof", ["-nP", "-a", "-p", String(process.pid), "-d", String(fd), "-F", "tn"], { + encoding: "utf8", timeout: 10_000, maxBuffer: 1024 * 1024 + }).split("\n"); + const type = details.find((line) => line.startsWith("t"))?.slice(1); + if (type === "KQUEUE") kind = "KQUEUE"; + else if (details.includes("n/dev/null")) kind = "/dev/null"; + else throw new Error(`Unexpected reuse of stdio FD ${fd}: ${type || "unknown"}; no PTY reuse is permitted`); + } + runtimeStdioReuse.set(fd, kind); + } +} + +async function runWorker(config) { + assert.ok(config["worker-report"], "the worker must have a dedicated result file"); + assert.ok(process.versions.electron, "the worker must run in actual Electron"); + // Allocate the result FD before closing stdio, so native reservations cannot + // accidentally overwrite the test's reporting channel. + const reportFd = openSync(config["worker-report"], "wx", 0o600); + const report = { ok: false, runtime: config.app ? "packaged" : "source", pid: process.pid, + cyclesRequested: config.cycles, cyclesCompleted: 0, stdioMask: config.stdioMask, + expectedLeak: config.expectLeak, normalExits: 0, stopped: 0, spawnFailures: 0 }; + let activeTerminal; + const watchdog = setTimeout(() => { + report.ok = false; + report.timedOut = true; + report.error = "Worker watchdog expired."; + try { activeTerminal?.kill("SIGKILL"); } catch {} + writeSync(reportFd, `${JSON.stringify(report, null, 2)}\n`); + closeSync(reportFd); + process.exit(1); + }, 600_000); + watchdog.unref(); + try { + // Follow the application's real ASAR import route. node-pty rewrites the + // helper's ASAR path itself; importing from .unpacked would double it. + const moduleRoot = config.app ? join(config.app, "Contents", "Resources", "app.asar", "node_modules", "node-pty") : + dirname(require.resolve("node-pty/package.json")); + const physicalModuleRoot = config.app ? join(config.app, "Contents", "Resources", "app.asar.unpacked", "node_modules", "node-pty") : moduleRoot; + const native = require(join(moduleRoot, "lib", "utils.js")).loadNativeModule("pty"); + assert.equal(native.dir, "../build/Release/", "the smoke must load the rebuilt addon, not an unpatched prebuild"); + const binary = join(physicalModuleRoot, "build", "Release", "pty.node"); + accessSync(join(physicalModuleRoot, "build", "Release", "spawn-helper"), constants.X_OK); + report.addonSha256 = createHash("sha256").update(readFileSync(binary)).digest("hex"); + report.electron = process.versions.electron; + report.arch = process.arch; + const pty = require(moduleRoot); + for (let fd = 0; fd < 3; fd++) if (config.stdioMask & (1 << fd)) closeSync(fd); + + async function cycle(stop = false) { + const terminal = pty.spawn(stop ? "/bin/sleep" : "/usr/bin/printf", stop ? ["30"] : ["PTY_CYCLE_OK\n"], { + name: "xterm-256color", cols: 80, rows: 24, cwd: process.cwd(), env: { PATH: "/usr/bin:/bin", TERM: "xterm-256color" } + }); + activeTerminal = terminal; + let output = ""; + const data = terminal.onData((text) => { output = (output + text).slice(-1024); }); + let ended = false; + let timer; + let subscription; + const exited = new Promise((resolveExit) => { + subscription = terminal.onExit((event) => { ended = true; resolveExit(event); }); + }); + let result; + try { + if (stop) terminal.kill("SIGTERM"); + result = await Promise.race([exited, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error("one PTY exceeded its five-second budget")), 5000); + })]); + } finally { + clearTimeout(timer); + data.dispose(); + if (!ended) { + try { terminal.kill("SIGKILL"); } catch (error) { if (error.code !== "ESRCH") throw error; } + await Promise.race([exited, delay(1000)]); + } + subscription.dispose(); + activeTerminal = undefined; + } + if (stop) report.stopped++; + else { + assert.equal(result.exitCode, 0); + assert.ok(output.includes("PTY_CYCLE_OK"), "a completed process must produce the actual command output"); + report.normalExits++; + } + await delay(20); + verifyClosedStdio(config.stdioMask); + } + + // Warm libuv's lazy resources in the SAME parent; no parent is restarted + // between any warmup, measured cycle, termination or failed native spawn. + for (let i = 0; i < 3; i++) await cycle(); + report.before = census(); + report.peakSettled = { ...report.before }; + for (let i = 0; i < config.cycles; i++) { + await cycle(i % 5 === 4); + const after = await settledCensus(report.before); + report.after = after; + for (const key of Object.keys(after)) report.peakSettled[key] = Math.max(report.peakSettled[key], after[key]); + report.cyclesCompleted++; + if (!returnedToBaseline(report.before, after)) { + report.leakDetected = true; + if (config.expectLeak) { + assert.ok(after.ptmx > report.before.ptmx, "the control must reproduce the specific extra-master leak"); + report.ok = true; + return; + } + // Abort at FIRST growth; do not use a failing implementation to consume + // the shared kernel pool for the remaining 400 cycles. + throw new Error(`FD growth after cycle ${i + 1}: ${JSON.stringify({ before: report.before, after })}`); + } + } + assert.equal(config.expectLeak, false, "the unpatched control unexpectedly passed; no RED evidence"); + + const argmax = Number(execFileSync("/usr/sbin/sysctl", ["-n", "kern.argmax"], { encoding: "utf8", timeout: 5000 })); + assert.ok(Number.isSafeInteger(argmax) && argmax > 0 && argmax <= 4 * 1024 * 1024); + for (let i = 0; i < 2; i++) { + // A missing target only fails INSIDE a successfully spawned helper. An + // oversized argv fails posix_spawn itself and exercises native ownership. + assert.throws(() => pty.spawn("/usr/bin/true", ["x".repeat(argmax * 2)], { + cwd: process.cwd(), env: { PATH: "/usr/bin:/bin" } + }), (error) => error.errno === osConstants.errno.E2BIG && /posix_spawnp failed/u.test(error.message), + "the failure must come from native posix_spawn with E2BIG, not JS validation or an unrelated exception"); + report.spawnFailures++; + const after = await settledCensus(report.before); + assert.ok(returnedToBaseline(report.before, after), `failed spawn leaked: ${JSON.stringify(after)}`); + verifyClosedStdio(config.stdioMask); + } + // A fresh PTY still starts after the complete run and both native failures. + await cycle(); + report.after = await settledCensus(report.before); + assert.ok(returnedToBaseline(report.before, report.after)); + report.freshPtyAfterFailures = true; + report.ok = true; + } catch (error) { + report.error = String(error.message).slice(0, 2000); + if (Number.isInteger(error.errno)) report.errno = error.errno; + if (typeof error.code === "string") report.code = error.code; + process.exitCode = 1; + } finally { + clearTimeout(watchdog); + report.stdioReusedByRuntime = [...runtimeStdioReuse].map(([fd, kind]) => ({ fd, kind })); + writeSync(reportFd, `${JSON.stringify(report, null, 2)}\n`); + closeSync(reportFd); + // A broken native watcher can retain a NAPI reference even after its child + // was killed. Save the real failure first, then end ONLY this test worker. + if (process.exitCode) process.exit(process.exitCode); + } +} diff --git a/scripts/smoke-usage-ui-packaged.mjs b/scripts/smoke-usage-ui-packaged.mjs new file mode 100644 index 00000000..7cb5e74c --- /dev/null +++ b/scripts/smoke-usage-ui-packaged.mjs @@ -0,0 +1,353 @@ +#!/usr/bin/env node +/** + * PACKAGED renderer smoke test for the usage history overlay. + * + * Loads the renderer and preload that ship inside the packaged app (app.asar → out/renderer, + * out/preload) — not the dev bundle — in a hidden window of the repository's Electron binary. + * The packaged MAIN process is never started: it pins the live data root, and this test must + * not touch live state. Instead this harness answers the preload's IPC channels itself: + * usage history with a labelled fixture, list-style channels with [], the rest with null. + * + * The usage history is not handwritten: the Node orchestrator writes synthetic SOURCE logs + * (Codex rollouts, a Claude transcript) into an isolated home, runs the real LocalUsageCollector + * inside the real UsageHistoryService, persists, reloads in a fresh service and hands the + * reloaded history to the harness (scripts/usage-acceptance-fixture.mjs). The harness then drives + * the packaged renderer (default 24 h / uncached, weight basis "all", period 1 h), scrapes the + * conditional blocks, and the orchestrator checks exact measured, local and external points, + * app/profile/session points and their conservation against hand-derived numbers. + * + * userData, HOME, source logs, state, logs and caches live in a fresh folder under TMPDIR (or + * --scratch ); network is blocked. No installed app is launched. + * + * node scripts/smoke-usage-ui-packaged.mjs [--app ] [--scratch ] [--keep] + * + * Run after packaging (npm run package). Exit 0 only when every check passes. + */ +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HARNESS_FLAG = "--packaged-usage-harness"; +const USAGE_CHANNEL = "usage-history:get"; +const PRIVATE_MARKERS = ["PRIVATE", "fixture-private-cwd"]; +const TIMEOUT_MS = 90_000; + +if (process.versions.electron && process.argv.includes(HARNESS_FLAG)) void runHarness(); +else await runOrchestrator(); + +function argument(name) { + const index = process.argv.indexOf(name); + return index >= 0 ? process.argv[index + 1] : undefined; +} + +function resolveAsar() { + const root = fileURLToPath(new URL("..", import.meta.url)); + const given = argument("--app"); + const candidates = given + ? [join(given, "Contents", "Resources", "app.asar"), join(given, "app.asar"), given] + : [ + join(root, "release", "mac-arm64", "CanvasTTY.app", "Contents", "Resources", "app.asar"), + join(root, "release", "mac", "CanvasTTY.app", "Contents", "Resources", "app.asar"), + join(root, "release", "linux-unpacked", "resources", "app.asar"), + join(root, "release", "win-unpacked", "resources", "app.asar") + ]; + return candidates.map((candidate) => resolve(candidate)).find((candidate) => candidate.endsWith(".asar") && existsSync(candidate)) ?? null; +} + +// ---------------------------------------------------------------- Node side + +async function runOrchestrator() { + const { spawn } = await import("node:child_process"); + const asar = resolveAsar(); + if (!asar) { + console.log("packaged usage UI smoke: FAIL — no packaged app.asar found (run `npm run package` or pass --app)"); + process.exitCode = 1; + return; + } + const keep = process.argv.includes("--keep"); + const scratchRoot = resolve(argument("--scratch") ?? tmpdir()); + await mkdir(scratchRoot, { recursive: true }); + const dir = await mkdtemp(join(scratchRoot, "canvastty-usage-packaged-smoke-")); + const nodeChecks = []; + const nodeCheck = (name, ok, detail = "") => { nodeChecks.push({ name, ok: Boolean(ok), detail }); }; + + const fixture = await import("./usage-acceptance-fixture.mjs"); + // Real collector → persisted state → reload, all inside `dir`. + const { isDeepStrictEqual } = await import("node:util"); + const built = await fixture.buildAcceptanceHistory(join(dir, "fixture")); + const historyFile = join(dir, "fixture", "reloaded-history.json"); + await writeFile(historyFile, JSON.stringify(built.history)); + nodeCheck("fixture: history persisted and reloaded by a fresh UsageHistoryService", built.history.samples.length > 0 && built.history.events.length === 6, + `${built.history.samples.length} samples, ${built.history.events.length} events`); + nodeCheck("fixture: reloaded report equals the report before dispose", + isDeepStrictEqual(built.reloadedReport.providers, built.reference.providers) && isDeepStrictEqual(built.reloadedReport.attribution, built.reference.attribution)); + nodeCheck("fixture: labelled as a test fixture", built.history.coverage.includes(fixture.FIXTURE_LABEL)); + nodeCheck("fixture: no conversation text or working directory left the collector", PRIVATE_MARKERS.every((marker) => !JSON.stringify(built.history).includes(marker))); + const expectedSessions = [...new Set(built.history.events.map((event) => event.session))]; + + const electronBinary = createRequire(import.meta.url)("electron"); + const env = { + ...process.env, HOME: join(dir, "home"), SMOKE_USAGE_DIR: dir, SMOKE_USAGE_ROOT: scratchRoot, SMOKE_USAGE_ASAR: asar, SMOKE_USAGE_HISTORY: historyFile, + SMOKE_USAGE_EXPECT_SESSIONS: JSON.stringify(expectedSessions) + }; + delete env.ELECTRON_RUN_AS_NODE; + delete env.ELECTRON_RENDERER_URL; + const child = spawn(electronBinary, [fileURLToPath(import.meta.url), HARNESS_FLAG, `--user-data-dir=${join(dir, "userData")}`], { + cwd: dir, env, stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { if (process.env.SMOKE_VERBOSE) process.stderr.write(chunk); }); + const killer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS + 10_000); + const code = await new Promise((done) => child.on("exit", (exitCode, signal) => done(signal ? 128 : exitCode ?? 1))); + clearTimeout(killer); + const line = stdout.split("\n").find((entry) => entry.startsWith("SMOKE_RESULT ")); + const result = line ? JSON.parse(line.slice("SMOKE_RESULT ".length)) : { ok: false, checks: [], error: "harness produced no result" }; + + const states = result.states ?? {}; + const captured = [["24h/uncached", states.initial], ["24h/all", states.weightAll], ["1h/uncached", states.period1h]]; + // Semantic checks of what the packaged renderer displayed, against hand-derived numbers. + const expected = fixture.expectations(); + const wants = { "24h/uncached": expected["24h"].uncached, "24h/all": expected["24h"].all, "1h/uncached": expected["1h"].uncached }; + const semantic = captured.flatMap(([label, state]) => state + ? [{ name: `[${label}] controls show this period and weight basis`, ok: state.period === label.split("/")[0] && state.weight === label.split("/")[1], detail: `${state.period}/${state.weight}` }, + ...fixture.checkRenderedState(label, state.blocks, wants[label], built.ids)] + : [{ name: `[${label}] state captured from the packaged renderer`, ok: false }]); + + console.log(`packaged renderer: ${asar}`); + console.log("input: SYNTHETIC TEST FIXTURE (collector → persist → reload)"); + const checks = [...nodeChecks, ...result.checks, ...semantic]; + for (const check of checks) console.log(`${check.ok ? "✔" : "✖"} ${check.name}${check.detail ? ` — ${check.detail}` : ""}`); + if (result.error) console.log(`✖ ${result.error}`); + if (result.screenshot) console.log(`screenshot: ${result.screenshot}`); + const ok = code === 0 && result.ok && checks.every((check) => check.ok); + if (keep || (!ok && result.screenshot)) console.log(`kept test directory: ${dir}`); + else await rm(dir, { recursive: true, force: true }); + const kind = "collector → persist → reload fixture"; + console.log(ok + ? `packaged usage UI smoke: PASS (packaged renderer; ${kind}; ${checks.length} checks)` + : `packaged usage UI smoke: FAIL (${kind}; exit ${code}; ${checks.filter((check) => !check.ok).length} failed)`); + process.exitCode = ok ? 0 : 1; +} + +// ---------------------------------------------------------------- Electron side + +async function runHarness() { + const { app, BrowserWindow, ipcMain, session } = createRequire(import.meta.url)("electron"); + const dir = process.env.SMOKE_USAGE_DIR; + const asar = process.env.SMOKE_USAGE_ASAR; + const checks = []; + const check = (name, ok, detail = "") => { checks.push({ name, ok: Boolean(ok), detail }); }; + const finish = (extra = {}) => { + const ok = !extra.error && checks.length > 0 && checks.every((entry) => entry.ok); + console.log(`SMOKE_RESULT ${JSON.stringify({ ok, checks, ...extra })}`); + app.exit(ok ? 0 : 1); + }; + const root = process.env.SMOKE_USAGE_ROOT; + const historyFile = process.env.SMOKE_USAGE_HISTORY; + if (!dir || !root || !resolve(dir).startsWith(resolve(root) + sep) || !asar || !historyFile || !resolve(historyFile).startsWith(resolve(dir) + sep)) { + console.log(`SMOKE_RESULT ${JSON.stringify({ ok: false, checks: [], error: "SMOKE_USAGE_DIR must be inside SMOKE_USAGE_ROOT, SMOKE_USAGE_HISTORY inside it, and SMOKE_USAGE_ASAR set" })}`); + app.exit(1); + return; + } + const history = JSON.parse(await readFile(historyFile, "utf8")); + app.setPath("userData", join(dir, "userData")); + app.setPath("sessionData", join(dir, "userData", "session")); + app.setPath("crashDumps", join(dir, "crashDumps")); + app.setAppLogsPath(join(dir, "logs")); + app.commandLine.appendSwitch("use-mock-keychain"); + app.commandLine.appendSwitch("disk-cache-dir", join(dir, "cache")); + let stage = "waiting for app ready"; + setTimeout(() => finish({ error: `timed out after ${TIMEOUT_MS} ms (${stage})` }), TIMEOUT_MS).unref(); + + await app.whenReady(); + const preload = join(asar, "out", "preload", "index.cjs"); + const rendererHtml = join(asar, "out", "renderer", "index.html"); + // Electron reads inside app.asar transparently; these are the shipped files. + const preloadSource = await readFile(preload, "utf8").catch(() => ""); + check("packaged preload present in app.asar", preloadSource.length > 0, preload); + check("packaged renderer present in app.asar", existsSync(rendererHtml), rendererHtml); + + let usageCalls = 0; + let servedHistory = history; + const channels = new Set([...preloadSource.matchAll(/["']([a-z][\w-]*:[\w:-]+)["']/g)].map((match) => match[1])); + channels.add(USAGE_CHANNEL); + for (const channel of channels) { + try { + ipcMain.handle(channel, () => { + if (channel === USAGE_CHANNEL) { + usageCalls += 1; + return servedHistory; + } + if (channel === "update:status") return { type: "idle" }; + if (channel === "materials:snapshot") return { revision: 0, materials: [], remarks: [], storage: { usedBytes: 0, limitBytes: 0 } }; + if (channel === "settings:get") throw new Error("smoke harness: settings are not provided (renderer defaults are used)"); + if (channel === "window:get-state") return { isMacOS: process.platform === "darwin", maximized: false, fullscreen: false }; + if (channel === "app:version") return "smoke-fixture"; + return /list|search|snapshot|sessions/i.test(channel) ? [] : null; + }); + } catch { /* already handled */ } + } + check("usage history channel exposed by packaged preload", preloadSource.includes(USAGE_CHANNEL)); + + const partition = "smoke-usage-packaged"; // in-memory only + const blocked = []; + session.fromPartition(partition).webRequest.onBeforeRequest((details, callback) => { + const allowed = /^(file|data|devtools|blob|canvastty-[a-z]+):/.test(details.url); + if (!allowed) blocked.push(details.url); + callback({ cancel: !allowed }); + }); + const win = new BrowserWindow({ + show: false, width: 1280, height: 900, + webPreferences: { partition, preload, contextIsolation: true, sandbox: false, nodeIntegration: false } + }); + win.webContents.on("will-navigate", (event) => event.preventDefault()); + win.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + const pageErrors = []; + win.webContents.on("console-message", (details) => { if (details.level === "error") pageErrors.push(details.message); }); + const js = (code) => win.webContents.executeJavaScript(code, true); + const waitFor = async (code, label, timeout = 20_000) => { + const started = Date.now(); + while (Date.now() - started < timeout) { + if (await js(code).catch(() => false)) return; + await new Promise((done) => setTimeout(done, 100)); + } + throw new Error(`timed out waiting for ${label}`); + }; + + try { + stage = "loading packaged renderer"; + await win.loadFile(rendererHtml); + check("renderer document comes from app.asar, not a dev server", (await js("location.href")).includes("app.asar"), await js("location.protocol")); + stage = "opening the usage panel"; + await waitFor(`Boolean(document.querySelector(".usage-history-trigger"))`, "usage trigger in packaged App"); + check("trigger renders closed", await js(`!document.querySelector('.usage-history[role="dialog"]') && + document.querySelector(".usage-history-trigger").getAttribute("aria-expanded") === "false"`)); + await js(`document.querySelector(".usage-history-trigger").focus(); document.querySelector(".usage-history-trigger").click(); true`); + await waitFor(`Boolean(document.querySelector('.usage-history[role="dialog"] .usage-history__section--conditional'))`, "conditional section"); + check("dialog is named, described and linked to its expanded trigger", await js(`(() => { + const dialog = document.querySelector('.usage-history[role="dialog"]'); + const trigger = document.querySelector(".usage-history-trigger"); + return dialog.getAttribute("aria-modal") === "true" && trigger.getAttribute("aria-expanded") === "true" && + trigger.getAttribute("aria-controls") === dialog.id && + Boolean(document.getElementById(dialog.getAttribute("aria-labelledby"))?.textContent.trim()) && + Boolean(document.getElementById(dialog.getAttribute("aria-describedby"))?.textContent.trim()); + })()`)); + await waitFor(`document.activeElement?.classList.contains("usage-history__close")`, "focus on Close"); + check("focus moves to Close", true); + const sessionIds = JSON.parse(process.env.SMOKE_USAGE_EXPECT_SESSIONS ?? "null") ?? [...new Set(history.events.map((event) => event.session))]; + // Session ids are matched on the tree rows' code[title] (layout-independent, never abbreviated). + const treeIds = `[...document.querySelectorAll('.usage-history[role="dialog"] .usage-history__tree-row--session code')].map((code) => code.getAttribute("title") || code.textContent)`; + try { + await waitFor(`((ids) => ${JSON.stringify(sessionIds)}.every((id) => ids.includes(id)))(${treeIds})`, "expected tree"); + } catch (error) { + const dump = await js(`JSON.stringify({ treeIds: ${treeIds}, rows: document.querySelectorAll('.usage-history[role="dialog"] .usage-history__tree-row').length, + blocks: [...document.querySelectorAll('.usage-history[role="dialog"] .usage-history__conditional-result')].map((b) => b.textContent.slice(0, 600)), + text: document.querySelector('.usage-history[role="dialog"]').textContent.slice(0, 1500) })`).catch(() => "unavailable"); + throw new Error(`${error.message}; expected ${JSON.stringify(sessionIds)}; DOM ${dump}`); + } + const text = await js(`document.querySelector('.usage-history[role="dialog"]').innerText`); + const html = await js(`document.querySelector('.usage-history[role="dialog"]').innerHTML`); + const has = (...needles) => needles.some((needle) => text.includes(needle)); + check("history loaded through the packaged preload bridge", usageCalls >= 1, `${usageCalls} call(s)`); + check("synthetic fixture label shown", text.includes(history.coverage.at(-1))); + check("provider sections and window series rendered", ["codex:primary", "claude:five_hour"].every((id) => text.includes(id))); + const scopes = [...new Set(history.samples.map((sample) => sample.scope))]; + check("account scopes shown shortened with full titles", await js(`((scopes) => scopes.every((scope) => + [...document.querySelectorAll('.usage-history[role="dialog"] code[title]')].some((code) => + code.title === scope && code.textContent !== scope && code.textContent.includes("…"))))(${JSON.stringify(scopes)})`)); + check("conditional estimate section is first", html.indexOf("usage-history__section--conditional") < html.indexOf("usage-history-quota")); + check("app → profile → session tree rendered", ["app", "profile", "session"].every((level) => html.includes(`usage-history__tree-row--${level}`))); + check("not-proven label present", has("The logs do not prove", "Логи не доказывают")); + check("no manual share input (no share field, no number/text inputs in the dialog)", + !html.includes("usage-history-share") && (await js(`document.querySelectorAll('.usage-history[role="dialog"] input:not([type="radio"]), .usage-history[role="dialog"] textarea').length`)) === 0); + check("measured change stays unattributed", has("Unattributed", "Не атрибутировано")); + check("private fixture fields never rendered", PRIVATE_MARKERS.every((marker) => !html.includes(marker))); + + // Scrape the conditional blocks in the current period / weight basis; numbers are checked by the orchestrator. + const scrape = async () => js(`(() => { + const dialog = document.querySelector('.usage-history[role="dialog"]'); + const checked = (name) => dialog.querySelector('input[name="' + name + '"]:checked')?.value ?? null; + return { + period: checked("usage-history-period"), + weight: checked("usage-history-weight"), + blocks: [...dialog.querySelectorAll(".usage-history__conditional-result")].map((block) => ({ + windowId: block.querySelector("h5 code")?.textContent ?? "", + summary: [...block.querySelectorAll(".usage-history__conditional-summary > li")].map((li) => li.innerText.trim()), + rows: [...block.querySelectorAll(".usage-history__tree-row")].map((row) => ({ + level: (row.className.match(/--(app|profile|session)\\b/) ?? [])[1] ?? "", + label: (row.cells[0].querySelector("code")?.getAttribute("title") || row.cells[0].textContent).trim(), + points: row.cells[1].innerText.trim(), + weight: row.cells[2].innerText.trim() + })) + })) + }; + })()`); + const choose = async (name, value) => { + stage = `choosing ${name}=${value}`; + await js(`document.querySelector('.usage-history[role="dialog"] input[name="${name}"][value="${value}"]').click(); true`); + await waitFor(`document.querySelector('.usage-history[role="dialog"] input[name="${name}"][value="${value}"]').checked`, `${name}=${value}`); + await new Promise((done) => setTimeout(done, 200)); + }; + const states = { initial: await scrape() }; + await choose("usage-history-weight", "all"); + states.weightAll = await scrape(); + await choose("usage-history-weight", "uncached"); + await choose("usage-history-period", "1h"); + states.period1h = await scrape(); + + let screenshot = null; + try { + const image = await win.webContents.capturePage(); + if (!image.isEmpty()) { + screenshot = join(dir, "usage-history-packaged-smoke.png"); + await writeFile(screenshot, image.toPNG()); + } + } catch { /* optional */ } + // Native input only: a dispatched DOM KeyboardEvent would hide a broken native path. + stage = "closing with native Escape"; + win.webContents.focus(); + win.webContents.sendInputEvent({ type: "keyDown", keyCode: "Escape" }); + win.webContents.sendInputEvent({ type: "keyUp", keyCode: "Escape" }); + await waitFor(`!document.querySelector('.usage-history[role="dialog"]')`, "native Escape closes dialog"); + check("native Escape closes dialog", true); + await waitFor(`document.activeElement === document.querySelector(".usage-history-trigger") && + document.activeElement.getAttribute("aria-expanded") === "false"`, "focus restored to closed trigger"); + check("focus restored to trigger", true); + + // Separate renderer-only edge fixture, not collector output. Do not alter the + // persisted history or the numeric acceptance states captured above. + servedHistory = { + ...history, + samples: history.samples.filter((sample) => sample.provider !== "claude"), + events: [...history.events, { + id: "fixture-unknown", provider: "unknown", app: "Fixture Unknown (test)", profile: "", session: "fixture-unknown-session", + from: history.collectedAt, to: history.collectedAt, input: 20, output: 2, cached: 0, timing: "event", + title: "PRIVATE fixture title", content: "PRIVATE fixture body", cwd: "/fixture-private-cwd" + }], + providerStatus: ["claude: unavailable (TEST FIXTURE — no quota observations)"] + }; + stage = "loading renderer-only unknown-provider fixture"; + const callsBeforeReopen = usageCalls; + await js(`document.querySelector(".usage-history-trigger").click(); true`); + await waitFor(`document.querySelector('.usage-history[role="dialog"]')?.textContent.includes("fixture-unknown-session")`, "unknown-provider fixture"); + const edgeText = await js(`document.querySelector('.usage-history[role="dialog"]').innerText`); + const edgeHtml = await js(`document.querySelector('.usage-history[role="dialog"]').innerHTML`); + const edgeHas = (...needles) => needles.some((needle) => edgeText.includes(needle)); + check("reopening reloads through packaged preload", usageCalls > callsBeforeReopen); + check("unknown / not recorded label", edgeHas("Unknown / not recorded locally", "Неизвестно / не записано локально")); + check("provider without quota observations is unknown", edgeHas("No quota observations for this provider", "Нет наблюдений квоты этого провайдера")); + check("unidentified provider is labelled", edgeHas("Provider not identified", "Провайдер не определён")); + check("unavailable provider status shown", edgeText.includes(servedHistory.providerStatus[0])); + check("private fields injected into renderer-only fixture never rendered", PRIVATE_MARKERS.every((marker) => !edgeHtml.includes(marker))); + check("no network requests left the harness", blocked.length === 0, blocked.slice(0, 3).join(", ")); + const usageErrors = pageErrors.filter((message) => /usage/i.test(message)); + check("no usage-related renderer console errors", usageErrors.length === 0, usageErrors.slice(0, 3).join(" | ")); + finish({ screenshot, states, otherConsoleErrors: pageErrors.length - usageErrors.length }); + } catch (error) { + finish({ error: `${error instanceof Error ? error.message : String(error)} (${stage}); console: ${pageErrors.slice(0, 3).join(" | ")}` }); + } +} diff --git a/scripts/usage-acceptance-fixture.mjs b/scripts/usage-acceptance-fixture.mjs new file mode 100644 index 00000000..2adcc428 --- /dev/null +++ b/scripts/usage-acceptance-fixture.mjs @@ -0,0 +1,204 @@ +/** + * Acceptance fixture for the packaged usage smoke: synthetic SOURCE logs (Codex rollouts, a Claude + * transcript) in an isolated home, read by the real LocalUsageCollector inside the real + * UsageHistoryService, persisted to an isolated directory, disposed and reloaded by a fresh + * service. The reloaded history is what the packaged renderer receives. + * + * Nothing outside `root` is read or written: the collector gets an explicit home and an empty + * environment (no HERMES_HOME / CODEX_HOME leak), limits snapshots are scripted, the clock is + * injected. Every timestamp is in the past relative to `now`, and the last collection runs long + * after every interval's maturity, so the renderer (which reports at its own Date.now()) sees + * mature, fully estimated intervals. + * + * Expected numbers are derived by hand below, not taken from the report under test. + */ +import { randomUUID } from "node:crypto"; +import { appendFile, mkdir, utimes, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { LocalUsageCollector } from "../src/main/services/LocalUsageCollector.ts"; +import { UsageHistoryService } from "../src/main/services/UsageHistoryService.ts"; +import { buildUsageReport } from "../src/shared/usageReport.ts"; + +export const FIXTURE_LABEL = "TEST FIXTURE — synthetic data, not real usage"; +const MIN = 60_000; +export const CODEX_SCOPE = "test-fixture-codex-scope-0001"; +export const CLAUDE_SCOPE = "test-fixture-claude-scope-0001"; +const iso = (ms) => new Date(ms).toISOString(); + +const tokens = ([input, cached, output]) => ({ input_tokens: input, cached_input_tokens: cached, output_tokens: output, reasoning_output_tokens: 0, total_tokens: input + output }); +const codexMeta = (id, at) => JSON.stringify({ timestamp: iso(at), type: "session_meta", payload: { id, timestamp: iso(at), cwd: "/tmp/fixture-private-cwd", originator: "codex_cli_rs", cli_version: "0.50.0", source: "cli", model_provider: "openai" } }); +const codexCount = (at, total, last) => JSON.stringify({ timestamp: iso(at), type: "event_msg", payload: { type: "token_count", info: { total_token_usage: tokens(total), last_token_usage: tokens(last), model_context_window: 258_400 }, rate_limits: null } }); +const assistant = (session, id, at, [input, read, write, output]) => JSON.stringify({ + parentUuid: null, isSidechain: false, userType: "external", cwd: "/tmp/fixture-private-cwd", sessionId: session, version: "2.1.0", + type: "assistant", uuid: randomUUID(), timestamp: iso(at), requestId: `req_${id}`, + message: { id, type: "message", role: "assistant", model: "claude-opus-5", content: [{ type: "text", text: "PRIVATE fixture body" }], stop_reason: null, + usage: { input_tokens: input, cache_creation_input_tokens: write, cache_read_input_tokens: read, output_tokens: output, service_tier: "standard" } } +}); +const windowOf = (id, usedPercent, resetsAt) => ({ id, bucketId: id.split(":")[0], slot: "primary", isDefaultBucket: true, label: "5h", usedPercent, used: null, limit: null, windowMinutes: 300, resetsAt }); + +/** + * Timeline (T = now rounded to the second): + * E1 = T − 3 h Codex session C: +2 pp in (E1+1, E1+2], 2 200 uncached tokens. (inside 24 h, outside 1 h) + * E2 = T − 30 m Codex A 3 300 uncached / B 1 100 uncached + 2 000 cached in (E2+1, E2+2] (+2 pp), + * Codex A only in (E2+2, E2+3] (+3 pp); Claude one session, +1 then +3 pp. + * Collection every minute around each episode and every minute over the last 12 minutes up to T − 1 m; + * the idle hours between episodes are a genuine collection gap (never measured). + */ +export function expectations() { + const allA = 3_300, allB = 3_100; // basis "all": input incl. cache + output + return { + "24h": { + uncached: { + "codex:primary": { measured: 7, local: 7, sessions: { A: 4.5, B: 0.5, C: 2 }, apps: { "Codex CLI": 7 } }, + "claude:five_hour": { measured: 4, local: 4, sessions: { claude: 4 }, apps: { "Claude Code": 4 } } + }, + all: { + "codex:primary": { measured: 7, local: 7, sessions: { A: 3 + 2 * allA / (allA + allB), B: 2 * allB / (allA + allB), C: 2 }, apps: { "Codex CLI": 7 } }, + "claude:five_hour": { measured: 4, local: 4, sessions: { claude: 4 }, apps: { "Claude Code": 4 } } + } + }, + "1h": { + uncached: { + "codex:primary": { measured: 5, local: 5, sessions: { A: 4.5, B: 0.5 }, apps: { "Codex CLI": 5 } }, + "claude:five_hour": { measured: 4, local: 4, sessions: { claude: 4 }, apps: { "Claude Code": 4 } } + } + } + }; +} + +async function writeLines(path, lines) { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, lines.join("\n") + "\n"); +} + +/** Builds source logs, runs the real collector/service, persists, reloads. */ +export async function buildAcceptanceHistory(root, now = Math.floor(Date.now() / 1000) * 1000) { + const home = join(root, "source-home"); + const directory = join(root, "usage-state"); + await mkdir(directory, { recursive: true }); // the service writes into an existing directory only + const E1 = now - 3 * 60 * MIN; + const E2 = now - 30 * MIN; + const reset = now + 4 * 60 * MIN; + const ids = { A: randomUUID(), B: randomUUID(), C: randomUUID(), claude: randomUUID() }; + const rollout = (id) => join(home, ".codex", "sessions", "2026", "09", "20", `rollout-2026-09-20T10-00-00-${id}.jsonl`); + const transcript = join(home, ".claude", "projects", "-tmp-fixture", `${ids.claude}.jsonl`); + const created = [[rollout(ids.C), E1 - 5 * MIN, codexMeta(ids.C, E1 - 5 * MIN)], [rollout(ids.A), E2 - 5 * MIN, codexMeta(ids.A, E2 - 5 * MIN)], [rollout(ids.B), E2 - 5 * MIN, codexMeta(ids.B, E2 - 5 * MIN)]]; + const pending = [ + [rollout(ids.C), E1 + 1.5 * MIN, codexCount(E1 + 1.5 * MIN, [2_000, 0, 200], [2_000, 0, 200])], + [rollout(ids.A), E2 + 1.5 * MIN, codexCount(E2 + 1.5 * MIN, [3_000, 0, 300], [3_000, 0, 300])], + [transcript, E2 + 1.6 * MIN, assistant(ids.claude, "msg_fixture_1", E2 + 1.6 * MIN, [100, 0, 0, 10])], + [rollout(ids.B), E2 + 1.7 * MIN, codexCount(E2 + 1.7 * MIN, [3_000, 2_000, 100], [3_000, 2_000, 100])], + [rollout(ids.A), E2 + 2.5 * MIN, codexCount(E2 + 2.5 * MIN, [5_000, 0, 500], [2_000, 0, 200])], + [transcript, E2 + 2.6 * MIN, assistant(ids.claude, "msg_fixture_2", E2 + 2.6 * MIN, [300, 0, 0, 30])] + ]; + // A file exists from its first line on; its mtime is the time of its last line (logs are backdated). + const feed = async (at) => { + while (created.length && created[0][1] <= at) { + const [path, time, line] = created.shift(); + await writeLines(path, [line]); + await utimes(path, new Date(time), new Date(time)); + } + while (pending.length && pending[0][1] <= at) { + const [path, time, line] = pending.shift(); + await mkdir(dirname(path), { recursive: true }); + await appendFile(path, line + "\n"); + await utimes(path, new Date(time), new Date(time)); + } + }; + + let clock = E1 - 10 * MIN; + let next = null; + const snapshot = (at, codex, claude, jitter) => ({ + fetchedAt: at, + providers: [ + { provider: "codex", state: "available", source: "codex-app-server", fetchedAt: at, accountScope: CODEX_SCOPE, windows: [windowOf("codex:primary", codex, reset)] }, + { provider: "claude", state: "available", source: "claude-usage-api", fetchedAt: at, accountScope: CLAUDE_SCOPE, windows: [windowOf("claude:five_hour", claude, reset + jitter)] } + ] + }); + const service = (start) => { + clock = start; + return new UsageHistoryService({ + directory, + limits: { getProviders: async () => structuredClone(next) }, + createCollector: (state) => new LocalUsageCollector(home, state, {}, {}), + now: () => clock, + initialDelayMs: 3_600_000 + }); + }; + const first = service(E1 - 10 * MIN); + await first.load(); + const run = async (at, codex, claude, jitter = 0) => { + clock = at; + await feed(at); + next = snapshot(at, codex, claude, jitter); + await first.collect(); + }; + // Episode 1: collection starts 10 minutes early, so the baseline warm-up is long over. + for (let minute = -4; minute <= 5; minute += 1) await run(E1 + minute * MIN, minute >= 2 ? 7 : 5, 20); + // Episode 2, with observed Claude reset jitter (all within 1 s). + const jitters = [0, -323, 396, 36, -67, 8]; + const codex2 = [10, 10, 12, 15, 15, 15]; + const claude2 = [20, 20, 21, 24, 24, 24]; + for (let index = 0; index < 6; index += 1) await run(E2 + index * MIN, codex2[index], claude2[index], jitters[index]); + // Steady tail up to T − 1 m: every interval matured (Codex 2 min, Claude 7 min) before the last run. + for (let at = now - 12 * MIN; at <= now - MIN; at += MIN) await run(at, 15, 24, 8); + const beforeDispose = first.get(); + const reference = buildUsageReport(beforeDispose, { now, period: "24h" }); + await first.dispose(); + + // Reload in a fresh service (fresh collector state from disk); no collection after reload. + const second = service(now); + await second.load(); + const reloaded = second.get(); + await second.dispose(); + const labelled = { ...reloaded, coverage: [...(reloaded.coverage ?? []), FIXTURE_LABEL] }; + return { home, directory, ids, history: labelled, reference, reloadedReport: buildUsageReport(reloaded, { now, period: "24h" }), now }; +} + +/** Last number in a rendered string; accepts "4,5" (ru) and "4.5" (en). Points never exceed 999. */ +export function lastNumber(text) { + const all = String(text).match(/\d+(?:[.,]\d+)?/g); + return all ? Number(all[all.length - 1].replace(",", ".")) : NaN; +} +const near = (actual, expected) => Math.abs(actual - expected) <= 0.005 + 1e-9; // UI rounds to 2 decimals + +/** + * Semantic checks of one rendered state (list of conditional account blocks scraped from the + * packaged renderer) against hand-derived expectations. Returns [{ name, ok, detail }]. + */ +export function checkRenderedState(label, blocks, expected, ids) { + const checks = []; + const check = (name, ok, detail = "") => checks.push({ name: `[${label}] ${name}`, ok: Boolean(ok), detail }); + const windows = Object.keys(expected); + check("one conditional block per expected window", blocks.length === windows.length, `${blocks.length} block(s): ${blocks.map((b) => b.windowId).join(", ")}`); + for (const windowId of windows) { + const want = expected[windowId]; + const block = blocks.find((b) => b.windowId === windowId); + if (!block) { check(`${windowId}: block rendered`, false); continue; } + const [measuredLine = "", localLine = "", externalLine = ""] = block.summary; + const measured = lastNumber(measuredLine), local = lastNumber(localLine), external = lastNumber(externalLine); + check(`${windowId}: measured +${want.measured} pp`, near(measured, want.measured), measuredLine); + check(`${windowId}: conditionally local ≈ ${want.local} pp`, near(local, want.local), localLine); + check(`${windowId}: external unknown 0..${want.measured} pp`, /\b0\b/.test(externalLine) && near(external, want.measured), externalLine); + const unestimated = block.summary.slice(3).find((line) => /^(Not estimated|Не оценено)/.test(line)); + const rest = unestimated ? lastNumber(unestimated.split("\n")[0]) : 0; + check(`${windowId}: local + not estimated = measured`, near(local + rest, measured), `${local} + ${rest} vs ${measured}`); + const apps = block.rows.filter((r) => r.level === "app"); + const profiles = block.rows.filter((r) => r.level === "profile"); + const sessions = block.rows.filter((r) => r.level === "session"); + const sum = (rows) => rows.reduce((total, r) => total + lastNumber(r.points), 0); + check(`${windowId}: apps ${JSON.stringify(want.apps)}`, + apps.length === Object.keys(want.apps).length && apps.every((r) => want.apps[r.label] !== undefined && near(lastNumber(r.points), want.apps[r.label])), + apps.map((r) => `${r.label}=${r.points}`).join("; ")); + const wantSessions = Object.fromEntries(Object.entries(want.sessions).map(([key, points]) => [ids[key], points])); + check(`${windowId}: sessions ${Object.entries(want.sessions).map(([k, v]) => `${k}=${Math.round(v * 1e4) / 1e4}`).join(" ")}`, + sessions.length === Object.keys(wantSessions).length && sessions.every((r) => wantSessions[r.label] !== undefined && near(lastNumber(r.points), wantSessions[r.label])), + sessions.map((r) => `${r.label.slice(0, 8)}…=${r.points}`).join("; ")); + check(`${windowId}: profile rows present and Σ profiles = Σ apps`, profiles.length >= apps.length && Math.abs(sum(profiles) - sum(apps)) <= 0.01 * profiles.length + 1e-9); + check(`${windowId}: Σ sessions = Σ apps = local (rounding ≤ 0.01/row)`, + Math.abs(sum(sessions) - local) <= 0.01 * sessions.length + 1e-9 && Math.abs(sum(apps) - local) <= 0.01 * apps.length + 1e-9, + `sessions ${sum(sessions)}, apps ${sum(apps)}, local ${local}`); + check(`${windowId}: every session row carries a token weight`, sessions.every((r) => /\d/.test(r.weight))); + } + return checks; +} diff --git a/src/main/editContextMenu.ts b/src/main/editContextMenu.ts new file mode 100644 index 00000000..ad553d44 --- /dev/null +++ b/src/main/editContextMenu.ts @@ -0,0 +1,36 @@ +import type { ContextMenuParams, MenuItemConstructorOptions, WebContents } from "electron"; +import type { LocaleId } from "../shared/contracts.ts"; + +type EditParams = Pick; + +/** + * The right-click menu for text: Cut/Copy/Paste/Select All in an editable field (an app field or a field in a plugin + * page), Copy for selected page text, nothing elsewhere. A page that handles its own right click (the canvas, a + * terminal) cancels the DOM event, and Electron then never asks for this menu. + */ +export function editContextMenuTemplate(params: EditParams, locale: LocaleId): MenuItemConstructorOptions[] { + const ru = locale === "ru"; + const label = { cut: ru ? "Вырезать" : "Cut", copy: ru ? "Скопировать" : "Copy", paste: ru ? "Вставить" : "Paste", selectAll: ru ? "Выбрать все" : "Select All" }; + if (params.isEditable) { + return [ + { role: "cut", label: label.cut, enabled: params.editFlags.canCut }, + { role: "copy", label: label.copy, enabled: params.editFlags.canCopy }, + { role: "paste", label: label.paste, enabled: params.editFlags.canPaste }, + { type: "separator" }, + { role: "selectAll", label: label.selectAll, enabled: params.editFlags.canSelectAll } + ]; + } + if (params.selectionText.length > 0) return [{ role: "copy", label: label.copy, enabled: params.editFlags.canCopy }]; + return []; +} + +export function attachEditContextMenu( + contents: WebContents, + locale: () => LocaleId, + popup: (template: MenuItemConstructorOptions[], contents: WebContents) => void +): void { + contents.on("context-menu", (_event, params) => { + const template = editContextMenuTemplate(params, locale()); + if (template.length) popup(template, contents); + }); +} diff --git a/src/main/index.ts b/src/main/index.ts index e2fb63e2..b9832dce 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -23,7 +23,9 @@ import { UpdateController, type UpdateAdapter } from "./services/updates/UpdateC import { ManualReleaseAdapter } from "./services/updates/ManualReleaseAdapter"; import { registerMaterialIpc } from "./ipc/registerMaterialIpc"; import { textResponse } from "./services/fileResponse"; +import { homedir } from "node:os"; import { SettingsStore } from "./services/SettingsStore"; +import { FileAccessService } from "./services/FileAccessService"; import { SkinRegistry } from "./services/SkinRegistry"; import { PixelSkinPackRegistry } from "./services/PixelSkinPackRegistry"; import { TerminalManager, reachesObservers, reachesRenderer } from "./services/TerminalManager"; @@ -31,13 +33,16 @@ import { TerminalRendererOutbox } from "./services/TerminalRendererOutbox"; import { AgentControlGateway } from "./services/agent-control/AgentControlGateway"; import { TerminalSessionStore } from "./services/TerminalSessionStore"; import { AgentChatHistoryService } from "./services/AgentChatHistoryService"; -import { LimitsService } from "./services/LimitsService"; +import { LimitsService, withoutAccountScope } from "./services/LimitsService"; +import { UsageHistoryService } from "./services/UsageHistoryService"; +import { LocalUsageCollector } from "./services/LocalUsageCollector"; import { createProviderCliRegistry, providerCliAvailability, type ProviderCliRegistry } from "./services/providerCliRegistry"; import { PluginManager } from "./services/PluginManager"; +import { MascotManager } from "./services/MascotManager"; import { PluginServiceSupervisor } from "./services/PluginServiceSupervisor"; import { LaunchPipeline } from "./services/LaunchPipeline"; import { EnvironmentRegistry } from "./services/EnvironmentRegistry"; @@ -50,6 +55,8 @@ import { PluginCards } from "./services/PluginCards"; import { GithubAuthService } from "./services/GithubAuthService"; import { PluginMediaService } from "./services/PluginMediaService"; import { MaterialService } from "./services/materials/MaterialService"; +import { HandoffService } from "./services/materials/HandoffService"; +import { electronImageOps } from "./services/materials/electronImageOps"; import { MATERIAL_SCHEME } from "../shared/materials.ts"; import { PluginSecretsService } from "./services/PluginSecretsService"; import { ProviderSecretsService } from "./services/ProviderSecretsService"; @@ -92,6 +99,7 @@ import { import { startupPageUrl } from "./startupPage"; import { mainWindowChromeOptions } from "./windowChrome"; import { markMainBoot, mainBootMarks } from "./bootMarks"; +import { attachEditContextMenu } from "./editContextMenu"; import { macApplicationMenuTemplate } from "./macApplicationMenu"; if (process.env.CANVASTTY_USER_DATA_DIR) { @@ -167,13 +175,16 @@ async function showCompanionBrowser():Promise<{title:string;url:string}> { let terminalManager: TerminalManager | null = null; let agentControl: AgentControlGateway | null = null; let limitsService: LimitsService | null = null; +let usageHistory: UsageHistoryService | null = null; let pluginManager: PluginManager | null = null; +let mascotManager: MascotManager | null = null; let pluginServices: PluginServiceSupervisor | null = null; let pluginSessions: PluginSessions | null = null; let pluginCards: PluginCards | null = null; let githubAuth: GithubAuthService | null = null; let pluginMediaService: PluginMediaService | null = null; let materialService: MaterialService | null = null; +let handoffService: HandoffService | null = null; let pluginSecretsService: PluginSecretsService | null = null; let providerSecretsService: ProviderSecretsService | null = null; let hermesHudService: HermesHudService | null = null; @@ -194,6 +205,8 @@ let appSurfaceReady = false; let pendingMenuUpdateCheck = false; let checkUpdatesFromMenu: (() => void) | null = null; let installMacMenu: (() => void) | null = null; +/** The locale of the text right-click menu; startApplication points it at the settings once they are loaded. */ +let editMenuLocale: () => LocaleId = () => (app.getLocale().toLowerCase().startsWith("ru") ? "ru" : "en"); let updateTimer: ReturnType | null = null; let updateInterval: ReturnType | null = null; let startupRunning = false; @@ -256,6 +269,8 @@ function createWindow(): BrowserWindow { preventMouseBindings: false, captureMacEditShortcuts: process.platform === "darwin" }); + attachEditContextMenu(window.webContents, () => editMenuLocale(), + (template, contents) => Menu.buildFromTemplate(template).popup({ window: BrowserWindow.fromWebContents(contents) ?? undefined })); // Crash recovery: a dead renderer must never leave the user staring at a // blank window. The application surface is reloaded in place — the same entry // startup loads — so services, sessions and their scrollback stay untouched @@ -327,6 +342,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { recoverKimiConfigurationOnStartup(kimiHomeDirectory); const userDataPath = app.getPath("userData"); const settings = new SettingsStore(userDataPath, app.getLocale(), process.platform, providerCliAvailability(providerClis)); + editMenuLocale = () => settings.get().locale; const terminalBorderSkins = new SkinRegistry(userDataPath); const pixelSkinPacks = new PixelSkinPackRegistry(userDataPath); pluginManager = new PluginManager(userDataPath); @@ -584,6 +600,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { agentControl?.observe(channel, payload); evenG2?.observe(channel, payload); pluginSessions?.observe(channel, payload); + handoffService?.observe(channel, payload); if (channel === IPC.terminalRemoved && "id" in payload) pluginCards?.forgetSession(payload.id); } if (reachesRenderer(payload)) rendererOutbox.push(channel, payload); @@ -771,9 +788,33 @@ async function initializeServices(ipc: IpcRegistrar): Promise { }; await applyAgentControlSetting(settings.get().agentControlEnabled); limitsService = new LimitsService(providerClis, app.getVersion()); + // Read-only accounting collection; history is stored in the normal app userData directory. + usageHistory = new UsageHistoryService({ + directory: userDataPath, + limits: limitsService, + createCollector: (state) => new LocalUsageCollector(homedir(), state, process.env) + }); + await usageHistory.load(); + usageHistory.start(); await Promise.all([browserReady, storesLoaded]); + mascotManager = new MascotManager( + userDataPath, + app.isPackaged ? join(process.resourcesPath, "mascot-pipeline") : join(app.getAppPath(), "assets", "mascot-pipeline"), + pluginManager, + (projects) => { if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(IPC.mascotsChanged, projects); }, + (pluginId, contributionId) => requestPluginCanvas({ pluginId, contributionId }) + ); + await mascotManager.initialize(); pluginManager.registerTokenProvider(() => githubAuth!.getToken()); protocol.handle("canvastty-media", (request) => pluginMediaService!.protocolResponse(request)); + // Session roots resolve a sessionId to the terminal's current working + // directory through TerminalManager's metadata snapshots; the renderer never + // supplies a path. Folder roots come from the native dialog or a persisted + // folderPath on restore. + const fileAccessService = new FileAccessService({ + resolveSessionCwd: (sessionId) => + terminalManager?.listMetadata().find((session) => session.id === sessionId)?.cwd + }); materialService = new MaterialService({ userDataPath: app.getPath("userData"), persist: () => settings.get().persistMaterials, @@ -784,8 +825,22 @@ async function initializeServices(ipc: IpcRegistrar): Promise { } }); await materialService.load(); - registerMaterialIpc(ipc, { materials: materialService, getMainWindow: () => mainWindow }); + handoffService = new HandoffService({ + materials: materialService, + terminals: terminalManager, + images: electronImageOps, + root: materialService.handoffsPath, + locale: () => settings.get().locale + }); + void handoffService.prune(); + registerMaterialIpc(ipc, { + materials: materialService, + handoffs: handoffService, + workingDirectory: (sessionId) => terminalManager?.pluginContext(sessionId)?.workingDirectory ?? null, + getMainWindow: () => mainWindow + }); registerIpc(ipc, { + mascots: mascotManager, settings, recheckProviderClis: async () => { providerClis!.refresh(); @@ -799,6 +854,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { terminals: terminalManager, limits: limitsService, agentChatHistory, + usageHistory, plugins: pluginManager, pluginServices, pluginCards, @@ -806,6 +862,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { pluginSecrets: pluginSecretsService, providerSecrets: providerSecretsService!, browser: browserService, + files: fileAccessService, githubAuth: githubAuth!, hermesHud: hermesHudService, launchFieldOptions: (pluginId, provider) => launchPipeline.fieldOptions(pluginId, provider), @@ -836,6 +893,9 @@ async function initializeServices(ipc: IpcRegistrar): Promise { if (active) browserService?.cancelCanvasNavigationGesture(); canvasNavigationInput?.setShortcutCaptureActive(active); }, + setCanvasNavigationTerminalEditFocus: (contents, active) => { + canvasNavigationInput?.setTerminalEditFocus(contents, active); + }, setCanvasNavigationPointerBinding: (input) => { canvasNavigationInput?.updatePointerBinding(input); }, @@ -856,7 +916,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { mobileRoot: app.isPackaged ? join(process.resourcesPath, "mobile-web") : join(app.getAppPath(), "integrations/mobile/dist"), providerAvailability: () => providerCliAvailability(providerClis!), speechWorker: app.isPackaged ? join(process.resourcesPath,"companion/asr_worker.py") : join(app.getAppPath(),"src/main/services/companion/asr_worker.py"), - limits: () => limitsService!.get(), openBrowser: showCompanionBrowser + limits: async () => withoutAccountScope(await limitsService!.get()), openBrowser: showCompanionBrowser }); await evenG2.load(); const assertCompanionSender = (event: Electron.IpcMainInvokeEvent):void => { @@ -1215,6 +1275,7 @@ void IPC.terminalData; async function shutdownServices(): Promise { diagnostics.record("info", "application", "shutdown.started"); + mascotManager?.dispose(); agentChatHistory?.dispose(); if (agentControl) await Promise.allSettled([agentControl.close()]); if (updateTimer) clearTimeout(updateTimer); @@ -1227,12 +1288,16 @@ async function shutdownServices(): Promise { const ptyExits = terminalManager?.waitForProcessExits().then((left) => { if (left > 0) console.warn(`CanvasTTY quit with ${left} terminal process(es) that did not exit after SIGKILL.`); }); + // Stop the timer first, then abort in-flight limit requests so the final save is prompt. + const usageHistoryClosed = usageHistory?.dispose(); limitsService?.dispose(); + await usageHistoryClosed; if (agentGateway) await Promise.allSettled([agentGateway.close()]); if (runtimeGateway) await Promise.allSettled([runtimeGateway.close()]); if (browserService) await Promise.allSettled([browserService.dispose()]); if (pluginServices) await Promise.allSettled([pluginServices.dispose()]); if (pluginManager) await Promise.allSettled([pluginManager.dispose()]); + await handoffService?.dispose(); if (materialService) await Promise.allSettled([materialService.dispose()]); await ptyExits; diagnostics.record("info", "application", "shutdown.completed"); @@ -1264,6 +1329,8 @@ async function openPluginWindow(pluginId: string, contributionId: string): Promi } }); pluginWindows.set(window, pluginId); + attachEditContextMenu(window.webContents, () => editMenuLocale(), + (template, contents) => Menu.buildFromTemplate(template).popup({ window: BrowserWindow.fromWebContents(contents) ?? undefined })); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); window.webContents.on("will-navigate", (event, url) => { if (!url.startsWith(`canvastty-plugin://${pluginId}/`)) event.preventDefault(); diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 72a6c87e..00d3feb6 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -1,6 +1,6 @@ import { realpath } from "node:fs/promises"; import { app, BrowserWindow, clipboard, dialog, shell } from "electron"; -import type { IpcMainEvent, IpcMainInvokeEvent, OpenDialogOptions } from "electron"; +import type { IpcMainEvent, IpcMainInvokeEvent, OpenDialogOptions, WebContents } from "electron"; import type { AppSettings, AgentChatHistoryProviderId, @@ -9,6 +9,7 @@ import type { CanvasNavigationPointerBindingInput, CustomTerminalBorderSkinId, CreateSessionRequest, + FileRootReference, PluginBrowserOpenResponse, PluginCanvasRequest, PluginLaunchFieldOptions, @@ -30,14 +31,17 @@ import { isPixelSkinSlot, isPixelTerminalBorderSkinId, type PixelSkinPackRegistr import { providerCliAvailability, type ProviderCliRegistry } from "../services/providerCliRegistry"; import type { TerminalManager } from "../services/TerminalManager"; import type { AgentChatHistoryService } from "../services/AgentChatHistoryService"; -import type { LimitsService } from "../services/LimitsService"; +import { withoutAccountScope, type LimitsService } from "../services/LimitsService"; +import type { UsageHistoryService } from "../services/UsageHistoryService"; import type { PluginManager } from "../services/PluginManager"; +import type { MascotManager } from "../services/MascotManager"; import type { PluginServiceSupervisor } from "../services/PluginServiceSupervisor"; import type { PluginCards } from "../services/PluginCards"; import type { PluginMediaService } from "../services/PluginMediaService"; import type { PluginSecretsService } from "../services/PluginSecretsService"; import type { ProviderSecretsService } from "../services/ProviderSecretsService"; import type { BrowserService } from "../services/BrowserService"; +import type { FileAccessService } from "../services/FileAccessService"; import type { IpcRegistrar } from "./IpcReadinessGate"; import { normalizePluginBrowserUrl } from "../services/browser/PluginBrowserOpenPolicy"; import { PluginBrowserOpenBroker } from "./PluginBrowserOpenBroker"; @@ -57,11 +61,13 @@ interface CriticalDependencies { } interface Dependencies { + mascots: MascotManager; settings: SettingsStore; recheckProviderClis(): Promise<{ availability: AgentCliAvailability; settings: AppSettings }>; terminals: TerminalManager; agentChatHistory: AgentChatHistoryService; limits: LimitsService; + usageHistory: UsageHistoryService; plugins: PluginManager; pluginServices: PluginServiceSupervisor; pluginCards: PluginCards; @@ -69,12 +75,14 @@ interface Dependencies { pluginSecrets: PluginSecretsService; providerSecrets: ProviderSecretsService; browser: BrowserService; + files: FileAccessService; githubAuth: GithubAuthService; hermesHud: HermesHudService; launchFieldOptions(pluginId: string, provider: ProviderId): Promise; getMainWindow(): BrowserWindow | null; applyBrowserSettings(settings: AppSettings): Promise | void; setCanvasNavigationShortcutCapture(active: boolean): void; + setCanvasNavigationTerminalEditFocus(contents: WebContents, active: boolean): void; setCanvasNavigationPointerBinding(input: CanvasNavigationPointerBindingInput): void; openPluginWindow(pluginId: string, contributionId: string): Promise; closePluginWindows(pluginId: string): void; @@ -193,11 +201,13 @@ export function registerCriticalIpc(ipcMain: IpcRegistrar, { } export function registerIpc(ipcMain: IpcRegistrar, { + mascots, settings, recheckProviderClis, terminals, agentChatHistory, limits, + usageHistory, plugins, pluginServices, pluginCards, @@ -205,12 +215,14 @@ export function registerIpc(ipcMain: IpcRegistrar, { pluginSecrets, providerSecrets, browser, + files, githubAuth, hermesHud, launchFieldOptions, getMainWindow, applyBrowserSettings, setCanvasNavigationShortcutCapture, + setCanvasNavigationTerminalEditFocus, setCanvasNavigationPointerBinding, openPluginWindow, closePluginWindows, @@ -218,7 +230,33 @@ export function registerIpc(ipcMain: IpcRegistrar, { requestPluginCanvas, broadcastPluginStorageChange }: Dependencies): void { + ipcMain.handle(IPC.mascotsList, (event) => { + assertMainRenderer(event, getMainWindow); + return mascots.list(); + }); + ipcMain.handle(IPC.mascotsStart, (event, image: Uint8Array) => { + assertMainRenderer(event, getMainWindow); + return mascots.start(image); + }); + ipcMain.handle(IPC.mascotsLink, (event, projectId: string, sessionId: string) => { + assertMainRenderer(event, getMainWindow); + return mascots.link(projectId, sessionId); + }); + ipcMain.handle(IPC.mascotsFail, (event, projectId: string, message: string) => { + assertMainRenderer(event, getMainWindow); + if (typeof message !== "string") throw new Error("Mascot error is invalid."); + return mascots.fail(projectId, message); + }); const pluginBrowserOpenBroker = new PluginBrowserOpenBroker(getMainWindow); + ipcMain.handle(IPC.mascotsRetry, (event, projectId: string) => { + assertMainRenderer(event, getMainWindow); + return mascots.retry(projectId); + }); + ipcMain.handle(IPC.mascotsOpenLog, async (event, projectId: string) => { + assertMainRenderer(event, getMainWindow); + const error = await shell.openPath(await mascots.errorLog(projectId)); + if (error) throw new Error(error); + }); // A surface reaches only its own plugin's services: the caller's plugin id is bound by the // renderer frame host or by the identity-checked plugin window, never taken from plugin code. const requestPluginService = (pluginId: string, values: Record): Promise => { @@ -261,6 +299,11 @@ export function registerIpc(ipcMain: IpcRegistrar, { if (typeof active !== "boolean") return; setCanvasNavigationShortcutCapture(active); }); + ipcMain.on(IPC.canvasNavigationTerminalEditFocus, (event, active: boolean) => { + assertMainRenderer(event, getMainWindow); + if (typeof active !== "boolean") return; + setCanvasNavigationTerminalEditFocus(event.sender, active); + }); ipcMain.on(IPC.canvasNavigationPointerBinding, (event, input: unknown) => { assertMainRenderer(event, getMainWindow); if (!isCanvasNavigationPointerBindingInput(input)) return; @@ -321,6 +364,63 @@ export function registerIpc(ipcMain: IpcRegistrar, { } }); + ipcMain.handle(IPC.filesListRoots, (event) => { + assertMainRenderer(event, getMainWindow); + return files.listRoots(); + }); + ipcMain.handle(IPC.filesRegisterRoot, async (event, reference: unknown) => { + assertMainRenderer(event, getMainWindow); + const parsed = fileRootReference(reference); + if (!parsed) throw new Error("File root reference is invalid."); + try { + return await files.registerRoot(parsed); + } catch (error) { + console.warn("CanvasTTY file root could not be registered.", error); + return null; + } + }); + ipcMain.handle(IPC.filesOpenFolder, async (event) => { + assertMainRenderer(event, getMainWindow); + const owner = getMainWindow(); + const options: OpenDialogOptions = { + title: "Choose a folder", + properties: ["openDirectory"] + }; + const result = owner + ? await dialog.showOpenDialog(owner, options) + : await dialog.showOpenDialog(options); + const selected = result.filePaths[0]; + if (result.canceled || !selected) return null; + try { + return files.registerFolderRoot(selected); + } catch (error) { + console.warn("CanvasTTY selected folder could not be opened.", error); + return null; + } + }); + ipcMain.handle(IPC.filesList, async (event, rootId: unknown, relativePath: unknown) => { + assertMainRenderer(event, getMainWindow); + return files.list(stringValue(rootId, "rootId"), fileRelativePath(relativePath)); + }); + ipcMain.handle(IPC.filesRead, async (event, rootId: unknown, relativePath: unknown) => { + assertMainRenderer(event, getMainWindow); + return files.read(stringValue(rootId, "rootId"), fileRelativePath(relativePath)); + }); + ipcMain.handle(IPC.filesSearch, async (event, rootId: unknown, query: unknown) => { + assertMainRenderer(event, getMainWindow); + return files.search(stringValue(rootId, "rootId"), fileSearchQuery(query)); + }); + ipcMain.handle(IPC.filesCloseRoot, (event, rootId: unknown) => { + assertMainRenderer(event, getMainWindow); + files.releaseRoot(stringValue(rootId, "rootId")); + return true; + }); + + ipcMain.handle(IPC.usageHistoryGet, (event) => { + assertMainRenderer(event, getMainWindow); + return usageHistory.get(); + }); + ipcMain.handle(IPC.limitsGet, (event) => { assertMainRenderer(event, getMainWindow); return limits.get(); @@ -642,7 +742,7 @@ export function registerIpc(ipcMain: IpcRegistrar, { } if (method === "limits.get") { plugins.assertPermission(pluginId, "limits:read"); - return { state: "ready", snapshot: await limits.get() }; + return { state: "ready", snapshot: withoutAccountScope(await limits.get()) }; } if (method === "hermesHud.getState") { plugins.assertPermission(pluginId, "hermes:hud"); @@ -984,6 +1084,43 @@ function stringValue(value: unknown, label: string): string { return value; } +function fileRootReference(value: unknown): FileRootReference | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const reference = value as Record; + if (reference.rootType === "session") { + if (typeof reference.sessionId !== "string" || reference.sessionId.length === 0 || reference.sessionId.length > 2_048) { + return null; + } + return { rootType: "session", sessionId: reference.sessionId }; + } + if (reference.rootType === "folder") { + if (reference.folderPath === undefined) return { rootType: "folder" }; + if ( + typeof reference.folderPath !== "string" + || reference.folderPath.length === 0 + || reference.folderPath.length > 4_096 + ) return null; + return { rootType: "folder", folderPath: reference.folderPath }; + } + return null; +} + +function fileRelativePath(value: unknown): string { + // An empty relative path addresses the root itself, so unlike stringValue it + // is allowed; FileAccessService re-enforces containment on every operation. + if (typeof value !== "string" || value.length > 2_048 || value.includes("\0")) { + throw new Error("File relative path is invalid."); + } + return value; +} + +function fileSearchQuery(value: unknown): string { + if (typeof value !== "string" || value.length > 512) { + throw new Error("File search query is invalid."); + } + return value; +} + function playlistContent(value: unknown): string { if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > 4 * 1024 * 1024) { throw new Error("Plugin playlist content is invalid or exceeds 4 MB."); diff --git a/src/main/ipc/registerMaterialIpc.ts b/src/main/ipc/registerMaterialIpc.ts index 0d72772b..bd70511a 100644 --- a/src/main/ipc/registerMaterialIpc.ts +++ b/src/main/ipc/registerMaterialIpc.ts @@ -1,8 +1,10 @@ +import { realpath, stat } from "node:fs/promises"; import { BrowserWindow, clipboard, dialog, shell } from "electron"; import type { IpcMainInvokeEvent, OpenDialogOptions } from "electron"; import type { MaterialsAddResult, Point } from "../../shared/contracts.ts"; import { IPC } from "../../shared/contracts.ts"; import type { MaterialService } from "../services/materials/MaterialService"; +import type { HandoffService } from "../services/materials/HandoffService"; import { captureRejection, fileUrlPaths, plistPaths, textPaths, windowsFileNames } from "../services/materials/materialClipboard.ts"; import { isId } from "../services/materials/materialState.ts"; import { assertMainRenderer } from "./registerIpc"; @@ -12,10 +14,12 @@ const MAX_CLIPBOARD_PATHS = 16; interface MaterialIpcDependencies { materials: MaterialService; + handoffs: HandoffService; + workingDirectory(sessionId: string): string | null; getMainWindow(): BrowserWindow | null; } -export function registerMaterialIpc(ipcMain: IpcRegistrar, { materials, getMainWindow }: MaterialIpcDependencies): void { +export function registerMaterialIpc(ipcMain: IpcRegistrar, { materials, handoffs, workingDirectory, getMainWindow }: MaterialIpcDependencies): void { ipcMain.handle(IPC.materialsSnapshot, (event) => { assertMainRenderer(event, getMainWindow); @@ -99,6 +103,35 @@ export function registerMaterialIpc(ipcMain: IpcRegistrar, { materials, getMainW assertMainRenderer(event, getMainWindow); return materials.deleteRemark(requireId(id)); }); + + ipcMain.handle(IPC.materialsPreviewHandoff, (event, draft: unknown) => { + assertMainRenderer(event, getMainWindow); + return handoffs.preview(draft); + }); + + ipcMain.handle(IPC.materialsSendHandoff, (event, draft: unknown) => { + assertMainRenderer(event, getMainWindow); + return handoffs.send(draft); + }); + + + ipcMain.handle(IPC.materialsPickResultsFolder, async (event, sessionId: unknown) => { + assertMainRenderer(event, getMainWindow); + const owner = BrowserWindow.fromWebContents(event.sender); + const defaultPath = typeof sessionId === "string" ? workingDirectory(sessionId) ?? undefined : undefined; + const options: OpenDialogOptions = { properties: ["openDirectory", "createDirectory"], ...(defaultPath ? { defaultPath } : {}) }; + const result = owner ? await dialog.showOpenDialog(owner, options) : await dialog.showOpenDialog(options); + const selected = result.filePaths[0]; + if (result.canceled || !selected) return null; + try { + if (!(await stat(selected)).isDirectory()) return null; + const folder = await realpath(selected); + handoffs.grantResultsFolder(folder); + return folder; + } catch { + return null; + } + }); } async function pickFiles(event: IpcMainInvokeEvent, multiple: boolean): Promise { diff --git a/src/main/services/CanvasNavigationOverride.ts b/src/main/services/CanvasNavigationOverride.ts index 80048276..a8f15c67 100644 --- a/src/main/services/CanvasNavigationOverride.ts +++ b/src/main/services/CanvasNavigationOverride.ts @@ -200,6 +200,8 @@ export class CanvasNavigationInputController { private readonly attachedContents = new Set(); private readonly onActiveChange: (state: CanvasNavigationOverrideState) => void; private menuShortcutContents: WebContents | null = null; + /** Contents whose renderer reported keyboard focus in a terminal surface or shortcut recorder. */ + private readonly terminalEditFocus = new WeakSet(); constructor( bindings: CanvasNavigationBindings, @@ -233,6 +235,19 @@ export class CanvasNavigationInputController { }); } + /** + * The renderer reports whether a terminal surface or shortcut recorder has keyboard focus. Both consume + * Command+C/V/A themselves, so those shortcuts bypass the Edit menu. The terminal-named API covers both; + * ordinary fields and plugin pages keep the menu's native Copy, Paste and Select All. + */ + setTerminalEditFocus(contents: WebContents, focused: boolean): void { + if (focused) this.terminalEditFocus.add(contents); + else { + this.terminalEditFocus.delete(contents); + if (this.menuShortcutContents === contents) this.releaseMenuShortcuts(); + } + } + setBindings(bindings: CanvasNavigationBindings): void { this.releaseMenuShortcuts(); const previous = this.state(); @@ -299,6 +314,7 @@ export class CanvasNavigationInputController { const wheelTransition = this.wheelTracker.update(keyboardInput); const navigationTransition = this.navigationTracker.update(keyboardInput); const macEditShortcut = options.captureMacEditShortcuts === true + && this.terminalEditFocus.has(contents) && topFrameFocused(contents) && input.type === "keyDown" && input.meta && !input.control && !input.alt && !input.shift && (input.code === "KeyA" || input.code === "KeyC" || input.code === "KeyV"); @@ -374,3 +390,10 @@ export class CanvasNavigationInputController { } } + +/** An iframe (a plugin page) with focus is never a terminal surface; contents without the property count as top. */ +function topFrameFocused(contents: WebContents): boolean { + const focused = (contents as { focusedFrame?: unknown }).focusedFrame; + const top = (contents as { mainFrame?: unknown }).mainFrame; + return focused === undefined || focused === null || top === undefined || focused === top; +} diff --git a/src/main/services/FileAccessService.ts b/src/main/services/FileAccessService.ts new file mode 100644 index 00000000..d1bb30cb --- /dev/null +++ b/src/main/services/FileAccessService.ts @@ -0,0 +1,454 @@ +import { constants, realpathSync, statSync } from "node:fs"; +import { open, readdir, stat } from "node:fs/promises"; +import { basename, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { TextDecoder } from "node:util"; +import { randomUUID } from "node:crypto"; +import type { + FileEntry, + FileReadResult, + FileRootDescriptor, + FileRootKind, + FileRootReference, + FileSearchResult +} from "../../shared/contracts"; + +export interface FileAccessLimits { + maxTextBytes: number; + maxImageBytes: number; + maxDirectoryEntries: number; + maxSearchResults: number; + maxSearchDepth: number; + skipDirectories: string[]; +} + +const DEFAULT_LIMITS: FileAccessLimits = { + maxTextBytes: 2_000_000, + maxImageBytes: 25 * 1024 * 1024, + maxDirectoryEntries: 2_000, + maxSearchResults: 500, + maxSearchDepth: 12, + skipDirectories: ["node_modules", ".git", "dist", "out", "build", ".cache"] +}; + +const BINARY_SAMPLE_BYTES = 64 * 1024; +const MAX_CONTROL_RATIO = 0.3; + +const STRICT_UTF8 = new TextDecoder("utf-8", { fatal: true }); + +const IMAGE_MIME: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".svg": "image/svg+xml" +}; + +interface RegisteredRoot { + rootId: string; + path: string; + label: string; + rootType: FileRootKind; + available: boolean; +} + +interface FileAccessOptions { + resolveSessionCwd: (sessionId: string) => string | undefined; + limits?: Partial; +} + +type ResolvedTarget = + | { ok: true; real: string; root: RegisteredRoot } + | { ok: false; reason: "not-permitted" | "unavailable" }; + +export class FileAccessService { + private readonly roots = new Map(); + private readonly resolveSessionCwd: (sessionId: string) => string | undefined; + private readonly limits: FileAccessLimits; + + constructor(options: FileAccessOptions) { + this.resolveSessionCwd = options.resolveSessionCwd; + this.limits = { + maxTextBytes: options.limits?.maxTextBytes ?? DEFAULT_LIMITS.maxTextBytes, + maxImageBytes: options.limits?.maxImageBytes ?? DEFAULT_LIMITS.maxImageBytes, + maxDirectoryEntries: options.limits?.maxDirectoryEntries ?? DEFAULT_LIMITS.maxDirectoryEntries, + maxSearchResults: options.limits?.maxSearchResults ?? DEFAULT_LIMITS.maxSearchResults, + maxSearchDepth: options.limits?.maxSearchDepth ?? DEFAULT_LIMITS.maxSearchDepth, + skipDirectories: options.limits?.skipDirectories + ? [...options.limits.skipDirectories] + : [...DEFAULT_LIMITS.skipDirectories] + }; + } + + registerSessionRoot(sessionId: string): FileRootDescriptor | null { + const cwd = this.resolveSessionCwd(sessionId); + if (typeof cwd !== "string" || cwd.length === 0) return null; + try { + const canonical = realpathSync(cwd); + if (!statSync(canonical).isDirectory()) return null; + return this.register(canonical, "session"); + } catch { + return null; + } + } + + registerFolderRoot(folderPath: string): FileRootDescriptor { + let canonical: string; + try { + canonical = realpathSync(folderPath); + } catch { + throw new Error("The selected folder is unavailable."); + } + if (!statSync(canonical).isDirectory()) throw new Error("The selected folder is not a directory."); + return this.register(canonical, "folder"); + } + + async registerRoot(reference: FileRootReference): Promise { + if (reference.rootType === "session") return this.registerSessionRoot(reference.sessionId); + // Folder roots are normally created by the native dialog; `folderPath` is + // only supplied when re-registering a persisted root after relaunch. A + // folder that has since disappeared resolves null (unavailable) rather than + // throwing, so restore can surface the missing-root state. + if (typeof reference.folderPath === "string" && reference.folderPath.length > 0) { + try { + return this.registerFolderRoot(reference.folderPath); + } catch { + return null; + } + } + return null; + } + + releaseRoot(rootId: string): void { + this.roots.delete(rootId); + } + + listRoots(): FileRootDescriptor[] { + return [...this.roots.values()].map((root) => this.describe(root)); + } + + async list(rootId: string, relativePath: string): Promise { + const root = this.requireRoot(rootId); + if (!root.available) throw new Error("The file root is unavailable."); + const real = await this.resolveForOperation(root, relativePath); + let metadata; + try { + metadata = await stat(real); + } catch { + throw new Error("The requested directory is unavailable."); + } + if (!metadata.isDirectory()) throw new Error("The requested path is not a directory."); + + let entries; + try { + entries = await readdir(real, { withFileTypes: true }); + } catch { + throw new Error("The requested directory could not be read."); + } + + const base = toPosix(relative(root.path, real)); + const result: FileEntry[] = []; + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + const kind: FileEntry["kind"] | null = entry.isDirectory() + ? "directory" + : entry.isFile() + ? "file" + : null; + if (kind === null) continue; + const relativeEntry = base.length === 0 ? entry.name : `${base}/${entry.name}`; + let size: number | null = null; + if (kind === "file") { + try { + size = (await stat(join(real, entry.name))).size; + } catch { + continue; + } + } + result.push({ name: entry.name, relativePath: relativeEntry, kind, size }); + } + result.sort(compareEntries); + return result.slice(0, this.limits.maxDirectoryEntries); + } + + async read(rootId: string, relativePath: string): Promise { + const root = this.roots.get(rootId); + if (!root || !root.available) return { kind: "unsupported", reason: "not-permitted" }; + const target = await this.resolveTarget(root, relativePath); + if (!target.ok) return { kind: "unsupported", reason: target.reason }; + + let handle; + try { + handle = await open(target.real, constants.O_RDONLY | constants.O_NOFOLLOW); + } catch (error) { + return { kind: "unsupported", reason: openFailureReason(error) }; + } + try { + const metadata = await handle.stat(); + if (!metadata.isFile()) return { kind: "unsupported", reason: "not-permitted" }; + + // Post-open containment re-checks. `O_NOFOLLOW` guards only the final + // component, so an intermediate directory swapped after `realpath` could + // redirect the open; re-resolve the requested path and compare the + // descriptor's real target (/proc/self/fd or /dev/fd) against the root. + // Residual race: the descriptor path is read after the open, so a swap + // between the open and these checks is caught where the platform exposes + // the fd path, but there is no atomic openat chain here by design. + let resolvedAfterOpen: string; + try { + resolvedAfterOpen = realpathSync(target.real); + } catch { + return { kind: "unsupported", reason: "unavailable" }; + } + if (!isContainedPath(root.path, resolvedAfterOpen)) { + return { kind: "unsupported", reason: "not-permitted" }; + } + const descriptorPath = await descriptorRealPath(handle); + if (descriptorPath !== null && !isContainedPath(root.path, descriptorPath)) { + return { kind: "unsupported", reason: "not-permitted" }; + } + + const size = metadata.size; + + const mediaType = IMAGE_MIME[extname(target.real).toLowerCase()]; + if (mediaType) { + if (size > this.limits.maxImageBytes) return { kind: "too-large", reason: "too-large", size }; + const data = await readExactly(handle, size); + return { + kind: "image", + mediaType, + dataUrl: `data:${mediaType};base64,${data.toString("base64")}`, + size + }; + } + + const limit = this.limits.maxTextBytes; + const inspected = await readExactly(handle, Math.min(size, limit)); + if (looksBinary(inspected)) return { kind: "unsupported", reason: "binary" }; + return { + kind: "text", + content: inspected.toString("utf8"), + truncated: size > limit, + size + }; + } finally { + await handle.close().catch(() => undefined); + } + } + + async search(rootId: string, query: string): Promise { + const root = this.requireRoot(rootId); + if (!root.available) throw new Error("The file root is unavailable."); + const needle = typeof query === "string" ? query.toLowerCase() : ""; + const skip = new Set(this.limits.skipDirectories); + const matches: string[] = []; + let truncated = false; + + const pending: Array<{ dir: string; depth: number }> = [{ dir: root.path, depth: 0 }]; + while (pending.length > 0) { + const { dir, depth } = pending.pop()!; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + if (skip.has(entry.name)) continue; + if (depth < this.limits.maxSearchDepth) { + pending.push({ dir: join(dir, entry.name), depth: depth + 1 }); + } + continue; + } + if (!entry.isFile()) continue; + if (!entry.name.toLowerCase().includes(needle)) continue; + if (matches.length >= this.limits.maxSearchResults) { + truncated = true; + pending.length = 0; + break; + } + matches.push(toPosix(relative(root.path, join(dir, entry.name)))); + } + } + + matches.sort((left, right) => left.localeCompare(right)); + return { relativePaths: matches, truncated }; + } + + revalidateRoot(rootId: string): boolean { + const root = this.roots.get(rootId); + if (!root) return false; + try { + const canonical = realpathSync(root.path); + const valid = canonical === root.path && statSync(root.path).isDirectory(); + root.available = valid; + return valid; + } catch { + root.available = false; + return false; + } + } + + private register(canonicalPath: string, rootType: FileRootKind): FileRootDescriptor { + const root: RegisteredRoot = { + rootId: randomUUID(), + path: canonicalPath, + label: basename(canonicalPath) || "root", + rootType, + available: true + }; + this.roots.set(root.rootId, root); + return this.describe(root); + } + + private describe(root: RegisteredRoot): FileRootDescriptor { + return { + rootId: root.rootId, + label: root.label, + available: root.available, + rootType: root.rootType, + // Folder roots carry their canonical path so the renderer can persist it + // and re-register the root after relaunch. Session roots omit it so the + // session cwd is never exposed across the bridge. + ...(root.rootType === "folder" ? { folderPath: root.path } : {}) + }; + } + + private requireRoot(rootId: string): RegisteredRoot { + const root = this.roots.get(rootId); + if (!root) throw new Error("The file root is not registered."); + return root; + } + + private async resolveForOperation(root: RegisteredRoot, relativePath: string): Promise { + const target = await this.resolveTarget(root, relativePath); + if (!target.ok) { + throw new Error( + target.reason === "not-permitted" + ? "The requested path is outside the root." + : "The requested path is unavailable." + ); + } + return target.real; + } + + private async resolveTarget(root: RegisteredRoot, relativePath: string): Promise { + if (!root.available) return { ok: false, reason: "not-permitted" }; + if (typeof relativePath !== "string" || relativePath.includes("\0") || relativePath.includes("\\")) { + return { ok: false, reason: "not-permitted" }; + } + if (isAbsolute(relativePath)) return { ok: false, reason: "not-permitted" }; + + const candidate = resolve(root.path, relativePath); + if (!isContainedPath(root.path, candidate)) return { ok: false, reason: "not-permitted" }; + + let real; + try { + // Canonicalize with the same resolver used when the root was registered + // (`realpathSync`). The async `realpath` can return a differently-cased or + // 8.3-shortened path on Windows, which would fail the containment check. + real = realpathSync(candidate); + } catch { + return { ok: false, reason: "unavailable" }; + } + if (!isContainedPath(root.path, real)) return { ok: false, reason: "not-permitted" }; + return { ok: true, real, root }; + } +} + +function isContainedPath(root: string, candidate: string): boolean { + // Windows paths are case-insensitive and `realpath` may surface a long (`\\?\`) + // form, so compare normalized values there instead of raw strings. + const normalizedRoot = normalizePathForCompare(root); + const normalizedCandidate = normalizePathForCompare(candidate); + if (normalizedCandidate === normalizedRoot) return true; + const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`; + return normalizedCandidate.startsWith(prefix); +} + +function normalizePathForCompare(value: string): string { + if (process.platform !== "win32") return value; + const withoutLongPrefix = value.startsWith("\\\\?\\") ? value.slice(4) : value; + return withoutLongPrefix.toLowerCase(); +} + +function toPosix(value: string): string { + return value.split(sep).join("/"); +} + +function compareEntries(left: FileEntry, right: FileEntry): number { + if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1; + return left.name.localeCompare(right.name); +} + +async function descriptorRealPath(handle: Awaited>): Promise { + for (const candidate of [`/proc/self/fd/${handle.fd}`, `/dev/fd/${handle.fd}`]) { + try { + return realpathSync(candidate); + } catch { + continue; + } + } + return null; +} + +function looksBinary(buffer: Buffer): boolean { + if (buffer.length === 0) return false; + if (buffer.includes(0)) return true; + const sample = buffer.length > BINARY_SAMPLE_BYTES ? buffer.subarray(0, BINARY_SAMPLE_BYTES) : buffer; + if (!isValidUtf8(sample)) return true; + return hasHighControlRatio(sample); +} + +function isValidUtf8(buffer: Buffer): boolean { + // A bounded read may split the final multi-byte code point, so a strict decode + // of the tail can look invalid even when the bytes are valid UTF-8. Decode + // strictly first; if that fails, retry with a streaming decoder that tolerates + // an incomplete trailing sequence while still rejecting genuinely invalid + // bytes. Trimming a fixed number of bytes is wrong: cutting three bytes off a + // complete two-byte character (or the middle of a four-byte one) corrupts it. + try { + STRICT_UTF8.decode(buffer); + return true; + } catch { + try { + new TextDecoder("utf-8", { fatal: true }).decode(buffer, { stream: true }); + return true; + } catch { + return false; + } + } +} + +function hasHighControlRatio(buffer: Buffer): boolean { + let control = 0; + for (const byte of buffer) { + if ((byte < 0x20 && byte !== 0x09 && byte !== 0x0a && byte !== 0x0d) || byte === 0x7f) { + control += 1; + } + } + return control / buffer.length > MAX_CONTROL_RATIO; +} + +function openFailureReason(error: unknown): "not-permitted" | "unavailable" { + const code = error && typeof error === "object" && "code" in error + ? (error as { code?: unknown }).code + : undefined; + if (code === "ELOOP" || code === "EISDIR") return "not-permitted"; + return "unavailable"; +} + +async function readExactly(handle: Awaited>, length: number): Promise { + if (length <= 0) return Buffer.alloc(0); + const buffer = Buffer.allocUnsafe(length); + let offset = 0; + while (offset < length) { + const { bytesRead } = await handle.read(buffer, offset, length - offset, offset); + if (bytesRead <= 0) break; + offset += bytesRead; + } + return offset === length ? buffer : buffer.subarray(0, offset); +} diff --git a/src/main/services/LimitsService.ts b/src/main/services/LimitsService.ts index 48dddc48..aa8e8a5b 100644 --- a/src/main/services/LimitsService.ts +++ b/src/main/services/LimitsService.ts @@ -33,10 +33,20 @@ const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"; const OPENCODE_GO_USAGE_URL = "https://opencode.ai/zen/go/v1/usage"; const GROK_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; +const LIMIT_PROVIDERS: readonly LimitProviderId[] = ["codex", "claude", "qwen", "kimi", "opencode", "grok"]; -interface CacheEntry { +interface CacheEntry { cachedAt: number; - value: LimitsSnapshot; + value: T; +} + +export interface LimitsServiceOptions { + codexIdleMs?: number; + /** Environment used to locate CODEX_HOME; defaults to the one the Codex app-server inherits. */ + environment?: Readonly>; + homeDirectory?: string; + cacheTtlMs?: number; + claude?: Omit; } interface JsonRpcResponse { @@ -68,8 +78,15 @@ export class LimitsService { private kimi: KimiWebUsageClient; private readonly providerClis: ProviderCliRegistry; private readonly clientVersion: string; + private readonly cacheTtlMs: number; + private readonly codexHome: string; + private readonly claudeOptions: Omit; private cache: CacheEntry | null = null; private inFlight: Promise | null = null; + private readonly providerCache = new Map>(); + private readonly providerLoads = new Map>(); + // undefined until the first Codex read; null when that read found no account. + private lastCodexScope: string | null | undefined = undefined; private lastGoodCodex: Extract | null = null; private lastGoodClaude: Extract | null = null; private lastGoodKimi: Extract | null = null; @@ -79,17 +96,21 @@ export class LimitsService { private readonly codexIdleMs: number; - constructor(providerClis: ProviderCliRegistry, clientVersion = "unknown", options: { codexIdleMs?: number } = {}) { + constructor(providerClis: ProviderCliRegistry, clientVersion = "unknown", options: LimitsServiceOptions = {}) { this.providerClis = providerClis; this.clientVersion = clientVersion; this.codexIdleMs = options.codexIdleMs ?? CODEX_IDLE_MS; + this.cacheTtlMs = options.cacheTtlMs ?? CACHE_TTL_MS; + const environment = options.environment ?? process.env; + this.codexHome = environment.CODEX_HOME || join(options.homeDirectory ?? homedir(), ".codex"); + this.claudeOptions = options.claude ?? {}; this.codex = new CodexAppServerClient(availableCli(providerClis, "codex"), clientVersion, this.codexIdleMs); this.kimi = new KimiWebUsageClient(availableCli(providerClis, "kimi")); } async get(): Promise { const now = Date.now(); - if (this.cache && now - this.cache.cachedAt < CACHE_TTL_MS) { + if (this.cache && now - this.cache.cachedAt < this.cacheTtlMs) { return structuredClone(this.cache.value); } @@ -110,6 +131,21 @@ export class LimitsService { return this.cache ? structuredClone(this.cache.value) : null; } + /** + * Reads only the requested providers (usage history needs Codex and Claude) without + * starting the others. Fresh results are shared with get(), so both callers together + * make at most one provider request per cache interval. + */ + async getProviders(providers: readonly LimitProviderId[]): Promise { + const full = this.cache && Date.now() - this.cache.cachedAt < this.cacheTtlMs ? this.cache.value : null; + const checkedAt = Date.now(); + const values = await Promise.all(providers.map((provider) => { + const cached = full?.providers.find((candidate) => candidate.provider === provider); + return cached ? Promise.resolve(cached) : this.loadShared(provider, checkedAt); + })); + return structuredClone({ fetchedAt: Date.now(), providers: values }); + } + dispose(): void { if (this.disposed) return; this.disposed = true; @@ -119,12 +155,15 @@ export class LimitsService { async providerClisRefreshed(): Promise { await this.inFlight?.catch(() => undefined); + await Promise.allSettled([...this.providerLoads.values()]); if (this.disposed) return; this.codex.dispose(); this.kimi.dispose(); this.codex = new CodexAppServerClient(availableCli(this.providerClis, "codex"), this.clientVersion, this.codexIdleMs); this.kimi = new KimiWebUsageClient(availableCli(this.providerClis, "kimi")); this.cache = null; + this.providerCache.clear(); + this.lastCodexScope = undefined; this.lastGoodCodex = null; this.lastGoodClaude = null; this.lastGoodKimi = null; @@ -134,23 +173,44 @@ export class LimitsService { private async refresh(): Promise { const checkedAt = Date.now(); - const [codex, claude, qwen, kimi, opencode, grok] = await Promise.all([ - this.loadCodex(checkedAt), - this.loadClaude(checkedAt), - this.loadQwen(checkedAt), - this.loadKimi(checkedAt), - this.loadOpenCode(checkedAt), - this.loadGrok(checkedAt) - ]); + const providers = await Promise.all(LIMIT_PROVIDERS.map((provider) => this.loadShared(provider, checkedAt))); const value: LimitsSnapshot = { fetchedAt: Date.now(), - providers: [codex, claude, qwen, kimi, opencode, grok] + providers }; this.cache = { cachedAt: Date.now(), value }; return value; } + /** One request per provider at a time, reused while younger than the cache interval. */ + private loadShared(provider: LimitProviderId, checkedAt: number): Promise { + const cached = this.providerCache.get(provider); + if (cached && Date.now() - cached.cachedAt < this.cacheTtlMs) return Promise.resolve(cached.value); + let load = this.providerLoads.get(provider); + if (!load) { + load = this.loadProvider(provider, checkedAt).then((value) => { + this.providerCache.set(provider, { cachedAt: Date.now(), value }); + return value; + }).finally(() => { + this.providerLoads.delete(provider); + }); + this.providerLoads.set(provider, load); + } + return load; + } + + private loadProvider(provider: LimitProviderId, checkedAt: number): Promise { + switch (provider) { + case "codex": return this.loadCodex(checkedAt); + case "claude": return this.loadClaude(checkedAt); + case "qwen": return this.loadQwen(checkedAt); + case "kimi": return this.loadKimi(checkedAt); + case "opencode": return this.loadOpenCode(checkedAt); + case "grok": return this.loadGrok(checkedAt); + } + } + private async loadCodex(checkedAt: number): Promise { if (this.providerClis.get("codex").state === "unavailable") { return unavailable("codex", "codex-app-server", "cli-not-found", checkedAt); @@ -160,7 +220,15 @@ export class LimitsService { } try { + // The account is bound around the request: a login change before, during or since + // the previous read cannot be attributed, so that observation stays account-unknown. + const before = await readCodexAccountScope(this.codexHome); + const accountChanged = this.lastCodexScope !== undefined && before !== this.lastCodexScope; + this.lastCodexScope = before; + // The long-lived app-server may still hold the previous login; start a fresh one. + if (accountChanged) this.codex.restart(); const raw = await this.codex.readRateLimits(); + const after = await readCodexAccountScope(this.codexHome); const windows = normalizeCodexLimits(raw); if (windows.length === 0) throw new LimitsAdapterError("not-authenticated"); @@ -169,7 +237,8 @@ export class LimitsService { state: "available", source: "codex-app-server", fetchedAt: Date.now(), - windows + windows, + accountScope: !accountChanged && before !== null && before === after ? before : null }; this.lastGoodCodex = available; return available; @@ -192,7 +261,13 @@ export class LimitsService { return unavailable("claude", "claude-usage-api", "cli-not-found", checkedAt); } try { - const raw = await readClaudeUsage(this.clientVersion); + let accountScope: string | null = null; + const raw = await readClaudeUsage(this.clientVersion, { + ...this.claudeOptions, + onScope: (scope) => { + accountScope = scope; + } + }); const windows = normalizeClaudeLimits(raw); if (windows.length === 0) throw new LimitsAdapterError("protocol-error"); @@ -201,7 +276,8 @@ export class LimitsService { state: "available", source: "claude-usage-api", fetchedAt: Date.now(), - windows + windows, + accountScope }; this.lastGoodClaude = available; return available; @@ -338,6 +414,12 @@ export interface ClaudeUsageReadOptions { accessToken: string, additionalHeaders: Record ) => Promise; + /** + * Receives a one-way fingerprint of the OAuth credential used for this request, never + * the credential. A token refresh starts a new epoch: it separates credentials + * conservatively but is not a permanent account identity. + */ + onScope?: (scope: string) => void; } export async function readClaudeUsage( @@ -352,6 +434,7 @@ export async function readClaudeUsage( const accessToken = cleanSecret(oauth?.accessToken); // Missing credentials describe only this runtime user; they do not prove anything about the user's plan. if (!accessToken) throw new LimitsAdapterError("not-authenticated"); + options.onScope?.(accountScopeFingerprint("claude", accessToken)); return (options.request ?? fetchUsageJson)(CLAUDE_USAGE_URL, accessToken, { "anthropic-beta": "oauth-2025-04-20", @@ -484,6 +567,41 @@ function selectGrokCredential(credentials: Record): GrokCredent return candidates[0] ?? null; } +/** + * Stable Codex account scope: a one-way fingerprint of tokens.account_id from the local + * login. API-key-only, keyring-stored, missing or unreadable auth is an unknown account. + */ +export async function readCodexAccountScope(codexHome: string): Promise { + try { + const auth = await readCredentialFile(join(codexHome, "auth.json"), "not-authenticated"); + const tokens = isRecord(auth.tokens) ? auth.tokens : null; + const accountId = cleanSecret(tokens?.account_id); + return accountId ? accountScopeFingerprint("codex", accountId) : null; + } catch { + return null; + } +} + +function accountScopeFingerprint(provider: "codex" | "claude", value: string): string { + return createHash("sha256") + .update(`canvastty-usage-scope/v1/${provider}\0`) + .update(value) + .digest("hex") + .slice(0, 24); +} + +/** Account scope is for local usage history only; plugins and paired devices never see it. */ +export function withoutAccountScope(snapshot: LimitsSnapshot): LimitsSnapshot { + return { + ...snapshot, + providers: snapshot.providers.map((provider) => { + if (provider.state === "unavailable") return { ...provider }; + const { accountScope: _omitted, ...rest } = provider; + return rest; + }) + }; +} + async function readCredentialFile(path: string, missingReason: LimitUnavailableReason): Promise> { try { const raw = await readFile(path, "utf8"); @@ -728,6 +846,12 @@ class CodexAppServerClient { } } + /** Drops the current app-server so the next read starts one with the current login. */ + restart(): void { + if (this.disposed) return; + this.resetConnection(); + } + dispose(): void { if (this.disposed) return; this.disposed = true; diff --git a/src/main/services/LocalUsageCollector.ts b/src/main/services/LocalUsageCollector.ts new file mode 100644 index 00000000..ad030c81 --- /dev/null +++ b/src/main/services/LocalUsageCollector.ts @@ -0,0 +1,609 @@ +import { execFile, type ExecFileOptions } from 'node:child_process'; +import { constants, type Dirent } from 'node:fs'; +import { access, open, readdir, readlink, realpath, stat, type FileHandle } from 'node:fs/promises'; +import { devNull, homedir } from 'node:os'; +import { basename, dirname, isAbsolute, join, resolve, posix, win32 } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; +import type { UsageEvent } from '../../shared/usageHistory.ts'; +import { + claudeFlush, claudeUsage, codexUsage, fingerprint, identifier, record, tokens, + type ClaudeFileState, type CodexFileState, type LogContext, +} from './usageReaders.ts'; + +const execute = promisify(execFile); + +/** Resolve only absolute PATH entries: never execute a same-named file in the working directory. */ +export async function discoverSqlite( + environment: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + executable: (path: string) => Promise = async path => { + try { await access(path, constants.X_OK); return (await stat(path)).isFile(); } catch { return false; } + }, +): Promise { + const paths = platform === 'win32' ? win32 : posix; + const key = platform === 'win32' ? Object.keys(environment).find(name => name.toUpperCase() === 'PATH') : 'PATH'; + for (const directory of (key ? environment[key] ?? '' : '').split(paths.delimiter)) { + if (!paths.isAbsolute(directory)) continue; + // A root-relative Windows path still depends on the current drive. + if (platform === 'win32' && !/^(?:[a-z]:[\\/]|[\\/]{2}[^\\/])/i.test(directory)) continue; + const candidate = paths.join(directory, platform === 'win32' ? 'sqlite3.exe' : 'sqlite3'); + if (await executable(candidate)) return candidate; + } + return null; +} + +const VERSION = 2; +const RETENTION = 30 * 86_400_000; +const CLOCK_SKEW = 60_000; +/** Claude Code may still append streamed entries of the newest message until its transcript idles. */ +const SETTLE = 5 * 60_000; +const MAX_LINE = 16 * 1024 * 1024; +const CHUNK = 256 * 1024; +const HEAD = 256; +const MAX_DEPTH = 8; +const BYTE_BUDGET = 128 * 1024 * 1024; + +type Source = 'codex' | 'claude'; +interface FileCursor { + /** `${dev}:${ino}`; together with the head fingerprint it detects replaced or truncated files. */ + node: string; + head: string; + headLength: number; + /** Bytes examined (the file size once read to its end) and mtime at that time. */ + size: number; + mtime: number; + /** Byte offset after the last consumed newline. */ + offset: number; + seen: number; + /** Inside a line longer than MAX_LINE, discarded up to its newline. */ + skip?: boolean; + codex?: CodexFileState; + claude?: ClaudeFileState; +} +/** [input, output, cached, observedAt, changedAt] of one Hermes accounting row. */ +type HermesBaseline = [number, number, number, number, number]; +interface HermesDatabase { node: string; at: number; rows: Record } +/** Serializable collector state: persist `collector.state` after the events of a collection are stored. */ +export interface CollectorState { + version?: number; + files?: Record; + hermes?: Record; +} +export interface CollectorOptions { + sqlite?: string; + /** Executable discovery is separate from the environment selecting source homes. */ + sqliteEnvironment?: NodeJS.ProcessEnv; + sqliteRunner?: (file: string, args: string[], options: ExecFileOptions) => Promise<{ stdout: string }>; + /** Maximum bytes of log backlog read per collection; the rest continues on the next one. */ + byteBudget?: number; +} +type Clean = Required; +/** + * `complete`: every discovered source was read to its end; absent default roots are normal. + * `lost`: this run consumed accounting it could not use (unparsable or oversized lines, active + * counters baselined without history), which no later run recovers. A lossy run is incomplete. + */ +interface Health { complete: boolean; lost: boolean } +interface Collection extends Health { events: UsageEvent[]; coverage: string[] } +interface Root { source: Source; path: string; label: string; profile: string } +interface LogFile { root: Root; path: string; key: string; node: string; size: number; mtime: number } +interface RootStats { files: number; read: number; bytes: number; invalid: number; oversized: number; unreadable: number; external: number; deep: number; problem: string } +interface HermesCandidate { database: string; total: number; events: UsageEvent[] } + +class SourceProblem extends Error {} + +const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value); +const counter = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; +const hex = (value: string): boolean => /^[0-9a-f]{24}$/.test(value); +const label = (name: string): string => name.replace(/[^\w.@+-]/g, '_').slice(0, 64) || 'unnamed'; +const errno = (error: unknown): string => String((error as NodeJS.ErrnoException)?.code ?? ''); +const text = (value: unknown): string => String(value ?? ''); +/** Hermes stores epoch seconds as REAL. */ +const milliseconds = (value: unknown): number | null => finite(value) && value > 0 ? Math.ceil(value * 1000) : null; + +function codexState(value: unknown): CodexFileState | undefined { + const v = record(value), state: CodexFileState = {}; + if (typeof v.session === 'string') state.session = v.session; + if (typeof v.app === 'string') state.app = v.app; + if (typeof v.external === 'string') state.external = v.external; + if (Array.isArray(v.total) && v.total.length === 3 && v.total.every(counter)) state.total = v.total as CodexFileState['total']; + if (finite(v.copyUntil)) state.copyUntil = v.copyUntil; + return Object.keys(state).length ? state : undefined; +} +function claudeState(value: unknown): ClaudeFileState | undefined { + const p = record(record(value).pending), usage = p.usage; + if (!identifier(p.id) || typeof p.session !== 'string' || !finite(p.from) || !finite(p.to)) return undefined; + if (!Array.isArray(usage) || usage.length !== 4 || !usage.every(counter)) return undefined; + return { pending: { id: p.id as string, session: p.session, from: p.from, to: p.to, usage: usage as [number, number, number, number] } }; +} +/** Persisted state comes from disk: anything malformed is dropped, which only causes a rebaseline or re-read. */ +export function sanitizeCollectorState(input: unknown): Clean { + const state = record(input), clean: Clean = { version: VERSION, files: {}, hermes: {} }; + if (Object.keys(state).length && state.version !== VERSION) throw new Error("Unsupported usage collector version"); + for (const [key, value] of Object.entries(record(state.files))) { + const c = record(value); + if (!hex(key) || typeof c.node !== 'string' || typeof c.head !== 'string' || ![c.headLength, c.size, c.offset].every(counter)) continue; + if (![c.mtime, c.seen].every(finite) || (c.offset as number) > (c.size as number)) continue; + const cursor: FileCursor = { node: c.node, head: c.head, headLength: c.headLength as number, size: c.size as number, + mtime: c.mtime as number, offset: c.offset as number, seen: c.seen as number }; + if (c.skip === true) cursor.skip = true; + const codex = codexState(c.codex), claude = claudeState(c.claude); + if (codex) cursor.codex = codex; + if (claude) cursor.claude = claude; + clean.files[key] = cursor; + } + for (const [key, value] of Object.entries(record(state.hermes))) { + const d = record(value), rows: Record = {}; + if (!hex(key) || typeof d.node !== 'string' || !finite(d.at)) continue; + for (const [row, baseline] of Object.entries(record(d.rows))) { + if (hex(row) && Array.isArray(baseline) && baseline.length === 5 && baseline.slice(0, 3).every(counter) && baseline.slice(3).every(finite)) { + rows[row] = baseline as HermesBaseline; + } + } + clean.hermes[key] = { node: d.node, at: d.at, rows }; + } + return clean; +} + +async function sideFile(path: string): Promise { + try { return (await stat(path)).size; } catch { return null; } +} +function sqliteProblem(error: unknown): string { + if (error instanceof SourceProblem) return error.message; + const e = error as NodeJS.ErrnoException & { stderr?: unknown; killed?: boolean }; + if (e.code === 'ENOENT') return 'sqlite3 is unavailable'; + if (e.killed) return 'read timed out'; + const text = String(e.stderr || e.message || ''); + // Older CLIs must fail closed: static SELECTs alone do not replace safe-mode protections. + if (/(?:unknown|unrecognized|unsupported) option[^\n]*-safe\b/i.test(text)) return 'sqlite3 lacks required -safe support; install a newer SQLite CLI'; + if (/not a database|malformed|corrupt/i.test(text)) return 'database is corrupt or not SQLite'; + if (/locked|busy/i.test(text)) return 'database is busy'; + if (/no such (table|column)/i.test(text)) return 'unsupported accounting schema'; + if (/unable to open/i.test(text)) return 'database cannot be opened read-only'; + return 'read failed'; +} +function hermesProvider(route: unknown): string { + // Only the ChatGPT-subscription Codex route is unambiguous; anthropic can be an API key or a + // subscription login, and other routes are not tracked quotas. + return String(route ?? '').trim().toLowerCase() === 'openai-codex' ? 'codex' : 'unknown'; +} +function sessionFromName(path: string): string { + return /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i.exec(path)?.[1] ?? `file-${fingerprint(path).slice(0, 12)}`; +} +async function readAt(handle: FileHandle, length: number, position: number): Promise { + const buffer = Buffer.allocUnsafe(length); + let filled = 0; + while (filled < length) { + const { bytesRead } = await handle.read(buffer, filled, length - filled, position + filled); + if (!bytesRead) break; + filled += bytesRead; + } + return buffer.subarray(0, filled); +} + +/** + * Collects token accounting of external agents from their local stores, strictly read-only: + * logs are opened O_RDONLY and SQLite through a PATH-resolved executable in safe, read-only + * mode that never creates a database or its -wal/-shm files. Only accounting fields leave here. + */ +export class LocalUsageCollector { + readonly state: CollectorState; + private readonly home: string; + private readonly environment: NodeJS.ProcessEnv; + private readonly sqlitePath: string | undefined; + private readonly sqliteEnvironment: NodeJS.ProcessEnv; + private readonly sqliteRunner: NonNullable; + private readonly byteBudget: number; + private queue: Promise = Promise.resolve(); + + constructor(home = homedir(), state: CollectorState = {}, environment: NodeJS.ProcessEnv = process.env, options: CollectorOptions = {}) { + this.home = home; + this.environment = environment; + this.sqlitePath = options.sqlite; + this.sqliteEnvironment = options.sqliteEnvironment ?? process.env; + this.sqliteRunner = options.sqliteRunner ?? (async (file, args, options) => { + const result = await execute(file, args, { ...options, encoding: 'utf8' }); + return { stdout: result.stdout }; + }); + this.byteBudget = options.byteBudget ?? BYTE_BUDGET; + const clean = sanitizeCollectorState(state); + for (const key of Object.keys(state)) delete (state as Record)[key]; + this.state = Object.assign(state, clean); + } + + /** Overlapping calls are serialized; state is committed only when a collection completes. */ + collect(now = Date.now()): Promise { + const run = this.queue.then(() => this.collectOnce(now)); + this.queue = run.catch(() => undefined); + return run; + } + + private async collectOnce(now: number): Promise { + const next: Clean = { version: VERSION, files: {}, hermes: {} }; + const events = new Map(), coverage: string[] = [], health: Health = { complete: true, lost: false }; + const add = (event: UsageEvent): void => { + if (event.to < now - RETENTION) return; + // Its source position is already consumed: a record dropped here is never read again. + if (event.to > now + CLOCK_SKEW || event.from > event.to) { health.complete = false; health.lost = true; return; } + const known = events.get(event.id); + if (!known || event.input + event.output > known.input + known.output) events.set(event.id, event); + }; + await this.collectHermes(now, next, add, coverage, health); + await this.collectLogs(now, next, add, coverage, health); + coverage.push( + 'Sources: Hermes state.db in the default home, every profiles/ (symlinks followed) and HERMES_HOME; Codex rollouts in ~/.codex and CODEX_HOME (sessions, archived_sessions); Claude Code transcripts in ~/.claude, ~/.config/claude and CLAUDE_CONFIG_DIR. Not covered: other OS users, undiscovered custom homes, other devices, web and desktop chat apps, and tools that keep no local log. This is not a complete record of usage on this device.', + 'Hermes: cumulative counters are baselined the first time a database is observed (no retrospective allocation); later increases are poll-delta intervals. Per-model rows are used; a session aggregate only for sessions without per-model rows, never both. Only the openai-codex route is attributed to codex; other routes (anthropic API key vs subscription is not recorded) are provider unknown.', + 'Logs: last 30 days, accounting fields only. Codex cumulative counters are differenced (cached is part of input, reasoning part of output) and identified per session lineage; Claude messages are deduplicated by API message id, including persisted print-mode (claude -p) sessions, which are attributed to Claude Code whatever launched them. The account or plan a log was billed to is not verified.', + ); + this.state.version = VERSION; + this.state.files = next.files; + this.state.hermes = next.hermes; + return { events: [...events.values()].sort((a, b) => a.to - b.to || (a.id < b.id ? -1 : 1)), coverage, ...health }; + } + + private directory(name: string): string | null { + const value = this.environment[name]?.trim(); + return value && isAbsolute(value) ? resolve(value) : null; + } + + private async hermesHomes(): Promise<{ path: string; label: string }[]> { + const fallback = join(this.home, '.hermes'), env = this.directory('HERMES_HOME'); + const roots = [fallback]; + if (env) roots.push(...(basename(dirname(env)) === 'profiles' ? [dirname(dirname(env)), env] : [env])); + const homes: { path: string; label: string }[] = []; + for (const root of new Set(roots)) { + homes.push({ path: root, label: root === fallback || basename(root) === '.hermes' ? 'default' : label(basename(root)) }); + let entries: Dirent[]; + try { entries = await readdir(join(root, 'profiles'), { withFileTypes: true }); } catch { continue; } + // A symlink to a sibling profile is an alias: the profile it names keeps the attribution. + const primary: { path: string; label: string }[] = [], aliases: { path: string; label: string }[] = []; + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.name.startsWith('.') || !(entry.isDirectory() || entry.isSymbolicLink())) continue; + const path = join(root, 'profiles', entry.name); + let alias = false; + if (entry.isSymbolicLink()) { + try { alias = dirname(resolve(dirname(path), await readlink(path))) === join(root, 'profiles'); } catch { /* Reported on access. */ } + } + (alias ? aliases : primary).push({ path, label: label(entry.name) }); + } + homes.push(...primary, ...aliases); + } + return homes; + } + + private async collectHermes(now: number, next: Clean, add: (event: UsageEvent) => void, coverage: string[], health: Health): Promise { + const databases = new Map(); + for (const home of await this.hermesHomes()) { + try { + const real = await realpath(join(home.path, 'state.db')); + if ((await stat(real)).isFile() && !databases.has(real)) databases.set(real, home.label); + } catch (error) { + if (!['ENOENT', 'ENOTDIR'].includes(errno(error))) { + health.complete = false; + coverage.push(`Hermes ${home.label}: state.db not accessible (${errno(error) || 'error'}).`); + } + } + } + if (!databases.size) coverage.push('Hermes: no state.db found.'); + const candidates = new Map(), locations = new Map>(); + for (const [database, profile] of databases) { + const key = fingerprint(database); + try { + const node = await stat(database, { bigint: true }).then(s => `${s.dev}:${s.ino}`); + const known = this.state.hermes![key]; + const previous = known?.node === node ? known : undefined; + const { rows, residual } = await this.hermesRows(database); + const baselines: Record = {}, bySession = new Map(); + let perModel = 0, aggregates = 0, invalid = 0; + for (const row of rows) { + const kind = row.k, id = row.id; + if ((kind !== 'm' && kind !== 's') || typeof id !== 'string' || !id) { invalid++; continue; } + const rowKey = fingerprint(JSON.stringify(kind === 'm' ? ['m', id, text(row.model), text(row.provider), text(row.url), text(row.mode), text(row.task)] : ['s', id])); + const counters = [row.i, row.o, row.cr, row.cw].map(tokens); + const old = previous?.rows[rowKey]; + if (counters.some(value => value === null)) { + invalid++; + if (old) baselines[rowKey] = old; + continue; + } + if (kind === 'm') perModel++; else aggregates++; + if (!locations.has(id)) locations.set(id, new Set()); + locations.get(id)!.add(key); + const [input, output, read, write] = counters as number[]; + const value: [number, number, number] = [input + read + write, output, read]; + const created = milliseconds(row.fs), changed = milliseconds(row.ls); + // A row created after the previous observation holds only usage from that interval. + const base: HermesBaseline | undefined = old + ?? (previous && created !== null && created > previous.at ? [0, 0, 0, previous.at, previous.at] : undefined); + const active = (changed ?? now) >= now - RETENTION; + if (kind === 's' || active) baselines[rowKey] = [...value, now, changed ?? now]; + if (!base || value.some((v, index) => v < base[index])) { + // Usage of an active row up to its new baseline is never counted. + if (active && value.some(v => v > 0)) { health.complete = false; health.lost = true; } + continue; + } + const [dInput, dOutput, dCached] = value.map((v, index) => v - base[index]); + if (!dInput && !dOutput) continue; + const from = base[3], to = changed !== null && changed > from && changed <= now ? changed : now; + if (!bySession.has(id)) bySession.set(id, []); + bySession.get(id)!.push({ + id: fingerprint(JSON.stringify(['hermes', rowKey, base])), provider: hermesProvider(row.provider), app: 'Hermes', profile, + session: identifier(id) || `hermes-${fingerprint(id).slice(0, 12)}`, from, to, + input: dInput, output: dOutput, cached: Math.min(dCached, dInput), timing: 'poll-delta', + }); + } + next.hermes[key] = { node, at: now, rows: baselines }; + for (const [session, list] of bySession) { + if (!candidates.has(session)) candidates.set(session, []); + candidates.get(session)!.push({ database: key, total: list.reduce((sum, e) => sum + e.input + e.output, 0), events: list }); + } + const notes = [`${perModel} per-model rows`, ...(aggregates ? [`${aggregates} session aggregates without per-model rows`] : [])]; + if (!previous) notes.push('first observation, counters baselined'); + if (residual) notes.push(`${residual} sessions with aggregate usage beyond their per-model rows (difference not counted)`); + if (invalid) { health.complete = false; notes.push(`${invalid} rows with invalid counters skipped`); } + coverage.push(`Hermes ${profile}: ${notes.join('; ')}.`); + } catch (error) { + health.complete = false; + coverage.push(`Hermes ${profile}: ${sqliteProblem(error)}; not collected this time.`); + const known = this.state.hermes![key]; + if (known) next.hermes[key] = known; + } + } + for (const [key, known] of Object.entries(this.state.hermes!)) { + if (!next.hermes[key] && known.at >= now - RETENTION) next.hermes[key] = known; + } + // A session id present in several databases is a clone (copied profile or recovered store): + // only its largest change is counted, never the sum. + for (const list of candidates.values()) list.sort((a, b) => b.total - a.total || (a.database < b.database ? -1 : 1))[0].events.forEach(add); + const duplicated = [...locations.values()].filter(places => places.size > 1).length; + if (duplicated) coverage.push(`Hermes: ${duplicated} session ids appear in several databases; counted once.`); + } + + /** Selects accounting columns only, in a single statement so all rows share one snapshot. */ + private async hermesRows(database: string): Promise<{ rows: Record[]; residual: number }> { + const schema = await this.sqlite(database, "SELECT 'sessions' AS t, name FROM pragma_table_info('sessions') UNION ALL SELECT 'session_model_usage', name FROM pragma_table_info('session_model_usage')"); + const columns = (table: string): Set => new Set(schema.filter(row => row.t === table).map(row => row.name)); + const sessions = columns('sessions'), models = columns('session_model_usage'); + const usable = (set: Set, id: string): boolean => [id, 'input_tokens', 'output_tokens'].every(name => set.has(name)); + const bySession = usable(sessions, 'id'), byModel = usable(models, 'session_id'); + if (!bySession && !byModel) throw new SourceProblem('unsupported accounting schema'); + const text = (set: Set, name: string): string => set.has(name) ? `COALESCE(${name}, '')` : "''"; + const value = (set: Set, name: string, fallback: string): string => set.has(name) ? name : fallback; + const counters = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens']; + const sum = (set: Set, alias: string): string => counters.filter(name => set.has(name)).map(name => `COALESCE(${alias}.${name}, 0)`).join(' + '); + const parts: string[] = []; + if (byModel) { + parts.push(`SELECT 'm' AS k, session_id AS id, ${text(models, 'model')} AS model, ${text(models, 'task')} AS task, ${text(models, 'billing_provider')} AS provider, ` + + `${text(models, 'billing_mode')} AS mode, ${text(models, 'billing_base_url')} AS url, ` + + `${counters.map((name, index) => `${value(models, name, '0')} AS ${['i', 'o', 'cr', 'cw'][index]}`).join(', ')}, ` + + `${value(models, 'first_seen', 'NULL')} AS fs, ${value(models, 'last_seen', 'NULL')} AS ls FROM session_model_usage`); + } + if (bySession) { + parts.push(`SELECT 's' AS k, id AS id, ${text(sessions, 'model')} AS model, '' AS task, ${text(sessions, 'billing_provider')} AS provider, ` + + `${text(sessions, 'billing_mode')} AS mode, ${text(sessions, 'billing_base_url')} AS url, ` + + `${counters.map((name, index) => `${value(sessions, name, '0')} AS ${['i', 'o', 'cr', 'cw'][index]}`).join(', ')}, ` + + `${value(sessions, 'started_at', 'NULL')} AS fs, NULL AS ls FROM sessions` + + (byModel ? ' WHERE NOT EXISTS (SELECT 1 FROM session_model_usage u WHERE u.session_id = sessions.id)' : '')); + } + if (bySession && byModel) { + parts.push(`SELECT 'r', NULL, '', '', '', '', '', COUNT(1), 0, 0, 0, NULL, NULL FROM sessions s ` + + `WHERE EXISTS (SELECT 1 FROM session_model_usage u WHERE u.session_id = s.id) ` + + `AND ${sum(sessions, 's')} > (SELECT COALESCE(SUM(${sum(models, 'u')}), 0) FROM session_model_usage u WHERE u.session_id = s.id)`); + } + const rows = await this.sqlite(database, parts.join(' UNION ALL ')); + return { rows: rows.filter(row => row.k !== 'r'), residual: rows.filter(row => row.k === 'r').reduce((total, row) => total + (tokens(row.i) ?? 0), 0) }; + } + + /** + * A plain read-only connection creates a missing -shm file; readonly_shm never does. When no WAL + * frames can exist (no or empty -wal) an immutable read takes no locks and creates nothing. + */ + private async sqlite(database: string, sql: string): Promise[]> { + const target = (query: string): string => `${pathToFileURL(database).href}?${query}`; + const [wal, shm] = await Promise.all([sideFile(`${database}-wal`), sideFile(`${database}-shm`)]); + if (shm === null && wal) throw new SourceProblem('WAL without shared-memory index (writer stopped uncleanly); skipped to avoid a stale read'); + try { + return await this.query(target('mode=ro&readonly_shm=1'), sql); + } catch (error) { + if (!/unable to open/i.test(String((error as { stderr?: unknown }).stderr ?? ''))) throw error; + if (await sideFile(`${database}-wal`)) throw error; + return await this.query(target('mode=ro&immutable=1'), sql); + } + } + + private async query(target: string, sql: string): Promise[]> { + const executable = this.sqlitePath ?? await discoverSqlite(this.sqliteEnvironment); + if (!executable) throw new SourceProblem('sqlite3 is unavailable (not found on PATH)'); + const { stdout } = await this.sqliteRunner(executable, + ['-readonly', '-batch', '-bail', '-safe', '-init', devNull, '-json', '-cmd', '.timeout 2000', target, sql], + { shell: false, env: {}, timeout: 15_000, maxBuffer: 64 * 1024 * 1024, windowsHide: true }); + if (!stdout.trim()) return []; + let rows: unknown; + try { rows = JSON.parse(stdout); } catch { throw new SourceProblem('unexpected sqlite3 output'); } + if (!Array.isArray(rows)) throw new SourceProblem('unexpected sqlite3 output'); + return rows.map(record); + } + + private logRoots(): Root[] { + const roots: Root[] = []; + const add = (source: Source, base: string | null, folders: string[], name: string, profile: string): void => { + if (base) for (const folder of folders) roots.push({ source, path: join(base, folder), label: `${name}/${folder}`, profile }); + }; + const codex = this.directory('CODEX_HOME'), claude = this.directory('CLAUDE_CONFIG_DIR'); + add('codex', join(this.home, '.codex'), ['sessions', 'archived_sessions'], '~/.codex', 'default'); + if (codex !== join(this.home, '.codex')) add('codex', codex, ['sessions', 'archived_sessions'], 'CODEX_HOME', codex ? label(basename(codex)) : ''); + add('claude', join(this.home, '.claude'), ['projects'], '~/.claude', 'default'); + add('claude', join(this.home, '.config', 'claude'), ['projects'], '~/.config/claude', 'default'); + if (claude !== join(this.home, '.claude')) add('claude', claude, ['projects'], 'CLAUDE_CONFIG_DIR', claude ? label(basename(claude)) : ''); + return roots; + } + + private async collectLogs(now: number, next: Clean, add: (event: UsageEvent) => void, coverage: string[], health: Health): Promise { + const roots = this.logRoots(), stats = new Map(), directories = new Set(), found: LogFile[] = [], nodes = new Set(); + for (const root of roots) { + const s: RootStats = { files: 0, read: 0, bytes: 0, invalid: 0, oversized: 0, unreadable: 0, external: 0, deep: 0, problem: '' }; + stats.set(root, s); + const walk = async (directory: string, depth: number): Promise => { + let real: string; + try { real = await realpath(directory); } catch (error) { + if (depth === 0) s.problem = errno(error) === 'ENOENT' ? 'not present' : `not accessible (${errno(error) || 'error'})`; + return; + } + if (directories.has(real)) return; + directories.add(real); + if (depth > MAX_DEPTH) { s.deep++; return; } + let entries: Dirent[]; + try { entries = await readdir(real, { withFileTypes: true }); } catch (error) { + if (depth === 0) s.problem = `not readable (${errno(error) || 'error'})`; else s.unreadable++; + return; + } + for (const entry of entries) { + if (entry.name.startsWith('.')) continue; + const path = join(real, entry.name); + if (entry.isDirectory()) { await walk(path, depth + 1); continue; } + if (!entry.isFile() && !entry.isSymbolicLink()) continue; + try { + const info = await stat(path, { bigint: true }); + if (info.isDirectory()) { await walk(path, depth + 1); continue; } + if (!info.isFile() || !entry.name.endsWith('.jsonl')) continue; + const node = `${info.dev}:${info.ino}`, mtime = Number(info.mtimeMs); + if (nodes.has(node) || mtime < now - RETENTION) continue; + nodes.add(node); + s.files++; + const real = await realpath(path); + found.push({ root, path: real, key: fingerprint(real), node, size: Number(info.size), mtime }); + } catch (error) { + // A dangling symlink or a file removed meanwhile is not a log; anything else is unread. + if (errno(error) !== 'ENOENT') s.unreadable++; + } + } + }; + await walk(root.path, 0); + } + // A renamed file (e.g. Codex archiving) keeps its inode: its cursor moves instead of a re-read. + const present = new Set(found.map(file => file.key)), vacated = new Map(); + for (const [key, cursor] of Object.entries(this.state.files!)) if (!present.has(key)) vacated.set(cursor.node, key); + const claimed = new Set(); + let budget = this.byteBudget, deferred = 0; + for (const file of found.sort((a, b) => b.mtime - a.mtime)) { + const s = stats.get(file.root)!, key = file.key, moved = vacated.get(file.node); + const known = this.state.files![key] ?? (moved && !claimed.has(moved) ? this.state.files![moved] : undefined); + claimed.add(key); + if (moved && known === this.state.files![moved]) claimed.add(moved); + const context: LogContext = { profile: file.root.profile, session: sessionFromName(file.path) }; + if (known && known.node === file.node && known.size === file.size && known.mtime === file.mtime) { + const cursor = structuredClone(known); + cursor.seen = now; + if (cursor.claude?.pending && file.mtime <= now - SETTLE) { + const event = claudeFlush(cursor.claude, context); + if (event) add(event); + } + if (cursor.codex?.external) s.external++; + next.files[key] = cursor; + continue; + } + if (budget <= 0) { deferred++; if (known) next.files[key] = known; continue; } + try { + const result = await this.readLog(file, known, budget, now, context); + budget -= result.bytes; + if (result.partial) deferred++; + if (result.lost) { + health.complete = false; health.lost = true; + coverage.push(`${file.root.label}: a log was truncated, replaced or rewritten; unread accounting may have been lost.`); + } + s.read++; s.bytes += result.bytes; s.invalid += result.invalid; s.oversized += result.oversized; + if (result.cursor.codex?.external) s.external++; + result.events.forEach(add); + next.files[key] = result.cursor; + } catch { + s.unreadable++; + if (known) next.files[key] = known; + } + } + for (const [key, cursor] of Object.entries(this.state.files!)) { + if (!claimed.has(key) && !next.files[key] && cursor.seen >= now - RETENTION) next.files[key] = cursor; + } + for (const root of roots) { + const s = stats.get(root)!, name = `${root.source === 'codex' ? 'Codex' : 'Claude Code'} ${root.label}`; + if (s.problem && s.problem !== 'not present') health.complete = false; + if (s.unreadable || s.deep) health.complete = false; + // Unparsable and oversized lines are consumed: their accounting is not read again. + if (s.invalid || s.oversized) { health.complete = false; health.lost = true; } + if (s.problem) { coverage.push(`${name}: ${s.problem}.`); continue; } + const notes = [`${s.files} logs in window`, `${s.read} read (${(s.bytes / 1048576).toFixed(1)} MiB)`]; + if (s.invalid) notes.push(`${s.invalid} unparsable accounting lines`); + if (s.oversized) notes.push(`${s.oversized} oversized lines skipped`); + if (s.unreadable) notes.push(`${s.unreadable} entries unreadable`); + if (s.deep) notes.push(`${s.deep} directories beyond depth ${MAX_DEPTH}`); + if (s.external) notes.push(`${s.external} Hermes runtime rollouts excluded (counted from Hermes)`); + coverage.push(`${name}: ${notes.join(', ')}.`); + } + if (deferred) health.complete = false; + if (deferred) coverage.push(`Logs: read budget reached; ${deferred} changed logs continue next collection.`); + } + + /** Reads complete lines after the cursor; any failure discards this file's progress and events. */ + private async readLog(file: LogFile, known: FileCursor | undefined, budget: number, now: number, context: LogContext): + Promise<{ cursor: FileCursor; events: UsageEvent[]; bytes: number; invalid: number; oversized: number; partial: boolean; lost: boolean }> { + const handle = await open(file.path, 'r'); + try { + const info = await handle.stat({ bigint: true }); + if (!info.isFile()) throw new Error('not a regular file'); + const node = `${info.dev}:${info.ino}`, size = Number(info.size), mtime = Number(info.mtimeMs); + const head = await readAt(handle, Math.min(HEAD, size), 0); + const same = known && known.node === node && known.offset <= size && known.headLength <= head.length + && fingerprint(head.subarray(0, known.headLength)) === known.head; + const cursor: FileCursor = same ? structuredClone(known) : { node, head: '', headLength: 0, size, mtime, offset: 0, seen: now }; + Object.assign(cursor, { node, head: fingerprint(head), headLength: head.length, size, mtime, seen: now }); + const markers = file.root.source === 'codex' ? ['token_count', 'session_meta'] : ['"usage"']; + const events: UsageEvent[] = []; + let invalid = 0, oversized = 0; + const consume = (line: Buffer): void => { + if (!line.length || !markers.some(marker => line.includes(marker))) return; + let raw: unknown; + try { raw = JSON.parse(line.toString('utf8')); } catch { invalid++; return; } + const event = file.root.source === 'codex' ? codexUsage(raw, cursor.codex ??= {}, context) : claudeUsage(raw, cursor.claude ??= {}, context); + if (event) events.push(event); + }; + const start = cursor.offset; + let limit = Math.min(size, start + budget), position = start, parts: Buffer[] = [], length = 0; + while (position < limit) { + const data = await readAt(handle, Math.min(CHUNK, limit - position), position); + if (!data.length) break; + let from = 0; + while (from < data.length) { + const newline = data.indexOf(10, from), end = newline < 0 ? data.length : newline; + if (!cursor.skip) { + if (length + end - from > MAX_LINE) { parts = []; length = 0; cursor.skip = true; oversized++; } + else { parts.push(data.subarray(from, end)); length += end - from; } + } + if (newline < 0) break; + if (cursor.skip) delete cursor.skip; else consume(Buffer.concat(parts, length)); + parts = []; length = 0; from = newline + 1; + cursor.offset = position + from; + } + position += data.length; + if (cursor.skip) cursor.offset = position; + // A budget smaller than one line must still make progress: finish that line (bounded by MAX_LINE). + if (position >= limit && cursor.offset === start) limit = Math.min(size, start + MAX_LINE + 1); + } + // A final line without newline is consumed only when it is already a complete JSON object. + if (position === size && length && !cursor.skip) { + const line = Buffer.concat(parts, length); + try { + if (typeof JSON.parse(line.toString('utf8')) === 'object') { consume(line); cursor.offset = size; } + } catch { /* Still being written. */ } + } + if (cursor.offset === size && cursor.claude?.pending && mtime <= now - SETTLE) { + const event = claudeFlush(cursor.claude, context); + if (event) events.push(event); + } + // Stopping at the byte budget leaves size below the file size, so the rest is read next time. + cursor.size = position; + return { cursor, events, bytes: position - start + head.length, invalid, oversized, partial: position < size, lost: known !== undefined && !same }; + } finally { + await handle.close(); + } + } +} diff --git a/src/main/services/MascotManager.ts b/src/main/services/MascotManager.ts new file mode 100644 index 00000000..41033a65 --- /dev/null +++ b/src/main/services/MascotManager.ts @@ -0,0 +1,336 @@ +import { randomUUID } from "node:crypto"; +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { appendFile, mkdir, readFile, readdir, realpath, rename, stat, unlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { nativeImage } from "electron"; +import { isPathInside } from "../../agent-runtime/path-inside.mjs"; +import type { MascotLaunch, MascotProjectSummary } from "../../shared/contracts"; +import type { PluginManager } from "./PluginManager"; + +interface MascotRecord extends MascotProjectSummary { createdAt: number; installedBuildId?: string } +const MAX_IMAGE_BYTES = 20 * 1024 * 1024; +const PNG_SIGNATURE = Buffer.from("89504e470d0a1a0a", "hex"); +const execFileAsync = promisify(execFile); + +export class MascotManager { + private readonly root: string; + private readonly pipeline: string; + private readonly plugins: PluginManager; + private readonly changed: (projects: MascotProjectSummary[]) => void; + private readonly installed: (pluginId: string, contributionId: string) => void; + private readonly records = new Map(); + private timer: ReturnType | null = null; + private checking = false; + private readonly previews = new Set(); + private readonly rejectedResults = new Map(); + private readonly pendingRetries = new Set(); + + constructor(userDataPath: string, pipelinePath: string, plugins: PluginManager, + changed: (projects: MascotProjectSummary[]) => void, + installed: (pluginId: string, contributionId: string) => void) { + this.root = join(userDataPath, "mascots"); + this.pipeline = pipelinePath; + this.plugins = plugins; + this.changed = changed; + this.installed = installed; + } + + async initialize(): Promise { + await mkdir(this.root, { recursive: true }); + for (const entry of await readdir(this.root, { withFileTypes: true })) { + if (!entry.isDirectory() || !isProjectId(entry.name)) continue; + try { + const record = JSON.parse(await readFile(join(this.root, entry.name, "mascot-project.json"), "utf8")) as MascotRecord; + if (record.id === entry.name && isMascotRecord(record)) this.records.set(entry.name, record); + } catch { /* An incomplete project remains on disk for manual recovery. */ } + } + this.timer = setInterval(() => { void this.checkResults(); }, 3_000); + void this.checkResults(); + } + + dispose(): void { + if (this.timer) clearInterval(this.timer); + this.timer = null; + for (const child of this.previews) child.kill(); + this.previews.clear(); + } + + list(): MascotProjectSummary[] { + return [...this.records.values()].sort((a, b) => b.createdAt - a.createdAt) + .map(({ createdAt: _createdAt, ...record }) => ({ ...record, + registered: this.plugins.list().some((plugin) => plugin.manifest.id === record.pluginId && plugin.sourceUrl === `mascot:${record.id}`) + })); + } + + async start(value: Uint8Array): Promise { + await stat(join(this.pipeline, "SKILL.md")); + const template = await readFile(join(this.pipeline, "templates", "launch-prompt.txt"), "utf8"); + if (!template) throw new Error("Mascot launch prompt is missing."); + if (!(value instanceof Uint8Array) || value.byteLength < 32 || value.byteLength > MAX_IMAGE_BYTES) { + throw new Error("Choose a PNG or JPEG up to 20 MB."); + } + const bytes = Buffer.from(value); + const isPng = bytes.subarray(0, 8).equals(PNG_SIGNATURE); + const isJpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; + if (!isPng && !isJpeg) throw new Error("The character image must be a PNG or JPEG."); + const image = nativeImage.createFromBuffer(bytes); + const size = image.getSize(); + if (image.isEmpty() || size.width < 32 || size.height < 32 || size.width > 4096 || size.height > 4096) { + throw new Error("The image dimensions must be between 32 and 4096 pixels."); + } + const bitmap = image.toBitmap(); + let visible = false; + for (let index = 3; index < bitmap.length; index += 4) { + if (bitmap[index] > 0) visible = true; + if (visible) break; + } + if (!visible) throw new Error("The image must contain visible character pixels."); + const python = await resolveMascotPython(); + + const id = randomUUID(); + const cwd = join(this.root, id); + await mkdir(this.root, { recursive: true }); + const uploadPath = join(this.root, `.upload-${id}.${isJpeg ? "jpg" : "png"}`); + await writeFile(uploadPath, bytes, { flag: "wx" }); + try { + await execFileAsync(python, [join(this.pipeline, "scripts", "prepare_input.py"), "--image", uploadPath, "--project", cwd], + { timeout: 15_000, maxBuffer: 4096, windowsHide: true }); + } finally { + await unlink(uploadPath); + } + const imagePath = join(cwd, "references", "character.png"); + const originalPath = join(cwd, "references", isJpeg ? "character.jpg" : "character.png"); + const previewUrl = await this.startPreview(python, cwd); + const record: MascotRecord = { id, name: "New mascot", status: "creating", createdAt: Date.now() }; + await this.save(record); + this.records.set(id, record); + this.changed(this.list()); + + const prompt = template + .replaceAll("[PROJECT_DIR]", cwd) + .replaceAll("[PIPELINE_DIR]", this.pipeline) + .replaceAll("[PREVIEW_URL]", previewUrl) + .trim() + `\n\nThe original upload is preserved at ${originalPath}. Intake has already prepared ${imagePath}; do not run prepare_input.py again. ` + + (isJpeg ? `The JPEG working copy has corrected EXIF orientation and PNG encoding; it still needs the skill's full-body image-generation preparation and background removal. ` : "") + + `Python 3.10+ with Pillow and NumPy was verified at ${python}. Use this exact executable for every pipeline script, not a bare python command. ` + + `Absolute script paths: qa_frames.py = ${join(this.pipeline, "scripts", "qa_frames.py")}; ` + + `scaffold_plugin.py = ${join(this.pipeline, "scripts", "scaffold_plugin.py")}; ` + + `build_sprite.py = ${join(this.pipeline, "scripts", "build_sprite.py")}; ` + + `preview_animation.py = ${join(this.pipeline, "scripts", "preview_animation.py")}; ` + + `finalize_mascot.py = ${join(this.pipeline, "scripts", "finalize_mascot.py")}. ` + + `Pass ${join(cwd, "character.json")} as the config argument and quote paths when invoking them in the shell.`; + return { project: this.list()[0]!, cwd, imagePath, prompt, previewUrl }; + } + + private startPreview(python: string, cwd: string): Promise { + return new Promise((resolveUrl, reject) => { + const child = spawn(python, [join(this.pipeline, "scripts", "serve_preview.py"), cwd, "--port", "0"], + { windowsHide: true, stdio: ["ignore", "pipe", "ignore"] }); + this.previews.add(child); + let output = ""; + let settled = false; + const fail = (): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.kill(); + reject(new Error("The mascot preview server could not start.")); + }; + const timer = setTimeout(fail, 10_000); + child.once("error", fail); + child.once("exit", () => { this.previews.delete(child); fail(); }); + child.stdout?.on("data", (chunk: Buffer) => { + if (settled) return; + output += chunk.toString("utf8"); + if (output.length > 4096) return fail(); + if (!output.includes("\n")) return; + try { + const value = JSON.parse(output.split("\n")[0]!) as { url?: string }; + if (!value.url || !/^http:\/\/127\.0\.0\.1:\d+\/$/.test(value.url)) return fail(); + settled = true; + clearTimeout(timer); + resolveUrl(value.url); + } catch { fail(); } + }); + }); + } + + async link(projectId: string, sessionId: string): Promise { + const record = this.require(projectId); + if (!/^[0-9a-f-]{36}$/i.test(sessionId)) throw new Error("Codex session ID is invalid."); + record.sessionId = sessionId; + await this.save(record); + this.changed(this.list()); + } + + async fail(projectId: string, message: string): Promise { + const record = this.require(projectId); + if (record.status === "ready") return; + await this.recordError(record, "creation", new Error(message)); + } + + async retry(projectId: string): Promise { + const record = this.require(projectId); + if (this.checking) { this.pendingRetries.add(projectId); return; } + this.rejectedResults.delete(projectId); + if (record.status === "failed") record.status = "creating"; + await this.save(record); + this.changed(this.list()); + // An active check owns the record; the next poll handles a retry queued during it. + await this.checkResults(); + } + + async errorLog(projectId: string): Promise { + this.require(projectId); + const project = await realpath(join(this.root, projectId)); + const path = await realpath(join(project, "installation-error.log")); + if (!isPathInside(project, path)) throw new Error("Mascot log path escapes its project."); + return path; + } + + private async recordError(record: MascotRecord, stage: string, cause: unknown): Promise { + const error = cause as { message?: string; stack?: string; stderr?: string; stdout?: string }; + const detail = [error?.stack ?? error?.message ?? String(cause), error?.stdout, error?.stderr].filter(Boolean).join("\n"); + record.errorLogPath = join(this.root, record.id, "installation-error.log"); + await appendFile(record.errorLogPath, `\n${new Date().toISOString()} stage=${stage} requested=${record.requestedBuildId ?? "unknown"}\n${detail}\n`); + const lines = (error?.stderr || error?.message || String(cause)).trim().split(/\r?\n/).filter(Boolean); + record.error = (lines.at(-1) ?? "Mascot installation failed.").slice(0, 500); + record.errorStage = stage; + if (record.status !== "ready") record.status = "failed"; + await this.save(record); + this.changed(this.list()); + } + + private require(id: string): MascotRecord { + const record = this.records.get(id); + if (!record) throw new Error("Mascot project does not exist."); + return record; + } + + private async save(record: MascotRecord): Promise { + const path = join(this.root, record.id, "mascot-project.json"); + const temporary = `${path}.tmp`; + await writeFile(temporary, JSON.stringify(record, null, 2)); + await rename(temporary, path); + } + + private async checkResults(): Promise { + if (this.checking) return; + this.checking = true; + try { + for (const record of this.records.values()) { + const path = join(this.root, record.id, "mascot-result.json"); + let raw: string; + try { raw = await readFile(path, "utf8"); } catch { continue; } + if (this.rejectedResults.get(record.id) === raw) continue; + let stage = "handoff"; + try { + const result = JSON.parse(raw) as Record; + if (result.status === "reviewed") continue; + if (result.installationApproved !== true || typeof result.buildId !== "string") continue; + if (record.installedBuildId === result.buildId) { + if (record.errorStage === "card load" && record.pluginId) { + stage = "card load"; + const plugin = this.plugins.list().find((item) => item.manifest.id === record.pluginId && item.sourceUrl === `mascot:${record.id}`); + if (!plugin) throw new Error("Installed mascot is not registered."); + this.installed(plugin.manifest.id, plugin.manifest.contributions[0]!.id); + delete record.error; + delete record.errorStage; + delete record.errorLogPath; + await this.save(record); + this.changed(this.list()); + } + continue; + } + record.requestedBuildId = result.buildId; + if (result.schemaVersion !== 1 || result.status !== "ready_for_host" + || typeof result.name !== "string" || typeof result.id !== "string" + || typeof result.pluginDirectory !== "string" || !Array.isArray(result.actions) || result.actions.length === 0 + || !result.actions.every((action) => typeof action === "string") + || !result.presentation || typeof result.presentation !== "object" + || (result.presentation as Record).transparentCard !== true + || (result.presentation as Record).resizable !== true) { + throw new Error("Mascot handoff is incomplete."); + } + const projectRoot = await realpath(join(this.root, record.id)); + stage = "package/review validation"; + const python = await resolveMascotPython(); + if (result.delivery !== undefined && result.delivery !== "draft" && result.delivery !== "final") throw new Error("Unknown mascot delivery mode."); + await execFileAsync(python, [join(this.pipeline, "scripts", "finalize_mascot.py"), join(projectRoot, "character.json"), "--verify-only", "--install-approved", ...(result.delivery === "draft" ? ["--draft"] : [])], { + timeout: 30_000, + maxBuffer: 1_048_576, + windowsHide: true + }); + const verified = JSON.parse(await readFile(path, "utf8")) as Record; + const build = JSON.parse(await readFile(join(projectRoot, "build-report.json"), "utf8")) as { build_id?: string }; + if (JSON.stringify(verified) !== JSON.stringify(result) || verified.buildId !== build.build_id || verified.installationApproved !== true) { + throw new Error("The approved mascot build changed before installation."); + } + if (verified.status !== "ready_for_host" || typeof verified.pluginDirectory !== "string" + || typeof verified.id !== "string" || typeof verified.name !== "string") { + throw new Error("Mascot finalization did not produce a valid handoff."); + } + if (!isAbsolute(verified.pluginDirectory)) throw new Error("Mascot plugin path must be absolute."); + const pluginRoot = await realpath(resolve(verified.pluginDirectory)); + if (!isPathInside(projectRoot, pluginRoot)) throw new Error("Mascot plugin path escapes its project."); + const config = JSON.parse(await readFile(join(projectRoot, "character.json"), "utf8")) as { id: string; name: string; plugin_dir: string }; + if (verified.id !== config.id || verified.name !== config.name || pluginRoot !== await realpath(resolve(projectRoot, config.plugin_dir))) throw new Error("Mascot handoff does not match the verified configuration."); + const review = JSON.parse(await readFile(join(projectRoot, result.delivery === "draft" ? "draft-review.json" : "review.json"), "utf8")) as { limitations?: string[] }; + const limits = result.delivery === "draft" ? review.limitations : []; + if (JSON.stringify(result.limitations ?? []) !== JSON.stringify(limits)) throw new Error("Mascot handoff limits differ from the accepted review."); + stage = "registration"; + const installed = await this.plugins.installLocalMascot(record.id, pluginRoot, verified.id); + record.status = "ready"; + record.name = verified.name.slice(0, 100); + record.pluginId = installed.manifest.id; + record.installedBuildId = String(verified.buildId); + record.delivery = result.delivery === "draft" ? "draft" : "final"; + record.limitations = Array.isArray(result.limitations) ? result.limitations.filter((item): item is string => typeof item === "string") : []; + delete record.error; + delete record.errorStage; + delete record.errorLogPath; + this.rejectedResults.delete(record.id); + await this.save(record); + this.changed(this.list()); + stage = "card load"; + this.installed(installed.manifest.id, installed.manifest.contributions[0]!.id); + } catch (cause) { + if (stage === "handoff" && cause instanceof SyntaxError) continue; + this.rejectedResults.set(record.id, raw); + await this.recordError(record, stage, cause); + } + } + } finally { + this.checking = false; + for (const id of this.pendingRetries) this.rejectedResults.delete(id); + this.pendingRetries.clear(); + } + } +} + +function isProjectId(value: string): boolean { return /^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(value); } + +async function resolveMascotPython(): Promise { + const bundled = join(homedir(), ".cache", "codex-runtimes", "codex-primary-runtime", "dependencies", "python"); + const candidates: Array<[string, string[]]> = []; + if (process.env.CANVASTTY_MASCOT_PYTHON) candidates.push([process.env.CANVASTTY_MASCOT_PYTHON, []]); + if (process.platform === "win32") candidates.push([join(bundled, "python.exe"), []], ["python", []], ["py", ["-3"]]); + else candidates.push(["python3", []], ["python", []], [join(bundled, "bin", "python3"), []]); + for (const [command, args] of candidates) { + try { + const { stdout } = await execFileAsync(command, [...args, "-c", + "import sys, PIL, numpy; sys.exit(1) if sys.version_info < (3, 10) else print(sys.executable)"], + { timeout: 5_000, maxBuffer: 1024, windowsHide: true }); + const executable = stdout.trim(); + if (isAbsolute(executable)) return executable; + } catch { /* Try the next installed Python. */ } + } + throw new Error("Mascot creation needs Python 3.10+ with Pillow and NumPy. Install those packages or set CANVASTTY_MASCOT_PYTHON to a working Python executable."); +} + +function isMascotRecord(value: MascotRecord): boolean { + return typeof value.name === "string" && typeof value.createdAt === "number" + && (value.status === "creating" || value.status === "ready" || value.status === "failed"); +} diff --git a/src/main/services/PluginManager.ts b/src/main/services/PluginManager.ts index 927b553a..0e86e719 100644 --- a/src/main/services/PluginManager.ts +++ b/src/main/services/PluginManager.ts @@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto"; import { gunzipSync } from "node:zlib"; import { chmod, + cp, lstat, copyFile, mkdir, @@ -290,7 +291,7 @@ export class PluginManager { const active = activeManifest(manifest, selectedModules); this.plugins.set(pluginId, { manifest, - sourceUrl: normalizeGithubUrl(record.sourceUrl), + sourceUrl: isLocalMascotSource(record.sourceUrl) ? record.sourceUrl : normalizeGithubUrl(record.sourceUrl), enabled: record.enabled, installedAt: record.installedAt, selectedModules, @@ -405,6 +406,62 @@ export class PluginManager { return this.withPluginLock(preview.manifest.id, () => this.installPending(pending, modules, destination)); } + async installLocalMascot(projectId: string, packageRoot: string, expectedPluginId: string): Promise { + if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(projectId)) throw new Error("Mascot project ID is invalid."); + const projectRoot = await realpath(join(dirname(this.pluginRoot), "mascots", projectId)); + const source = await realpath(packageRoot); + if (!isPathInside(projectRoot, source) || source === projectRoot) throw new Error("Mascot plugin escapes its project."); + await inspectPackage(source, 150 * 1024 * 1024); + const manifest = await readManifest(source); + if (manifest.id !== expectedPluginId) throw new Error("Mascot plugin ID differs from handoff."); + if (manifest.permissions.length !== 0 || manifest.services?.length || manifest.hooks?.length + || manifest.contributions.length !== 1 || manifest.contributions[0]?.kind !== "canvas-app") { + throw new Error("Mascot plugin must be a static canvas app without permissions."); + } + await assertManifestAssets(source, manifest); + return this.withPluginLock(manifest.id, async () => { + const previous = this.plugins.get(manifest.id); + if (previous && previous.sourceUrl !== `mascot:${projectId}`) throw new Error(`Plugin ${manifest.id} belongs to another source.`); + const staging = await mkdtemp(join(this.pluginRoot, ".mascot-")); + const destination = join(this.pluginRoot, manifest.id); + const backup = join(projectRoot, "revisions", `installed-${randomUUID()}`); + let backedUp = false; + let replaced = false; + try { + if (!previous) { + const existing = await lstat(destination).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + return null; + }); + if (existing) throw new Error(`Unregistered plugin directory exists: ${destination}. Preserve it and resolve ownership before retrying installation.`); + } + await cp(source, staging, { recursive: true, force: false }); + await inspectPackage(staging, 150 * 1024 * 1024); + if (previous) { + await mkdir(dirname(backup), { recursive: true }); + await rename(destination, backup); + backedUp = true; + } + await rename(staging, destination); + replaced = true; + const installed: InstalledPlugin = { + manifest, sourceUrl: `mascot:${projectId}`, enabled: true, installedAt: Date.now(), + selectedModules: [], enabledHooks: [], nativeCodeTrusted: false, decisionsMayAllow: false + }; + this.plugins.set(manifest.id, installed); + await this.persistRegistry(); + return structuredClone(installed); + } catch (error) { + if (previous) this.plugins.set(manifest.id, previous); else this.plugins.delete(manifest.id); + if (replaced) await rm(destination, { recursive: true, force: true }); + if (backedUp) await rename(backup, destination); + throw error; + } finally { + await rm(staging, { recursive: true, force: true }); + } + }); + } + private async installPending( { preview, packageRoot, directory }: PendingInstall, modules: string[], @@ -953,14 +1010,14 @@ export class PluginManager { async checkForUpdates(): Promise { const sources = [...this.plugins.values()] - .filter((plugin) => plugin.enabled && plugin.sourceUrl) + .filter((plugin) => plugin.enabled && plugin.sourceUrl && !isLocalMascotSource(plugin.sourceUrl)) .sort((left, right) => left.manifest.id.localeCompare(right.manifest.id)); // Batch: one GraphQL metadata round-trip for all manifests, then raw // fetches for present files — far fewer requests than one per plugin. const remoteVersions = await fetchRemoteManifestVersions(sources.map((plugin) => plugin.sourceUrl)); return this.withVersionsLock((versions) => { const installed = [...this.plugins.values()] - .filter((plugin) => plugin.enabled && plugin.sourceUrl) + .filter((plugin) => plugin.enabled && plugin.sourceUrl && !isLocalMascotSource(plugin.sourceUrl)) .sort((left, right) => left.manifest.id.localeCompare(right.manifest.id)); const updates: PluginUpdateStatus[] = []; for (const plugin of installed) { @@ -1975,10 +2032,10 @@ function validateContribution(value: unknown): PluginContribution { return { ...base, kind: "home-widget", defaultSize: validateGridSize(value.defaultSize) }; } if (value.kind === "canvas-app" || value.kind === "window") { - const defaultSize = validateWindowSize(value.defaultSize, "defaultSize", 320, 220); + const defaultSize = validateWindowSize(value.defaultSize, "defaultSize", value.kind === "canvas-app" ? 128 : 320, 220); const minSize = value.minSize === undefined ? null - : validateWindowSize(value.minSize, "minSize", 240, 140); + : validateWindowSize(value.minSize, "minSize", value.kind === "canvas-app" ? 128 : 240, 140); if (minSize && (minSize.width > defaultSize.width || minSize.height > defaultSize.height)) { throw new Error("Plugin contribution minSize must not exceed defaultSize."); } @@ -2008,7 +2065,7 @@ async function firstExistingFile(root: string, candidates: readonly string[]): P return null; } -async function inspectPackage(root: string): Promise { +async function inspectPackage(root: string, maxBytes = MAX_PACKAGE_BYTES): Promise { let entryCount = 0; let totalBytes = 0; const visit = async (directory: string): Promise => { @@ -2027,7 +2084,7 @@ async function inspectPackage(root: string): Promise { } if (!metadata.isFile()) throw new Error("Plugin packages may contain only regular files and directories."); totalBytes += metadata.size; - if (totalBytes > MAX_PACKAGE_BYTES) { + if (totalBytes > maxBytes) { throw new Error("Plugin package exceeds the 500 entry / 25 MB limit."); } } @@ -2035,6 +2092,10 @@ async function inspectPackage(root: string): Promise { await visit(root); } +function isLocalMascotSource(value: string): boolean { + return /^mascot:[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(value); +} + async function assertManifestAssets(root: string, manifest: PluginManifest): Promise { for (const contribution of manifest.contributions) { await containedFile(root, contribution.entry); diff --git a/src/main/services/SettingsStore.ts b/src/main/services/SettingsStore.ts index d8963b07..a4828a44 100644 --- a/src/main/services/SettingsStore.ts +++ b/src/main/services/SettingsStore.ts @@ -18,6 +18,8 @@ import type { CanvasWheelCaptureMode, CanvasPatternId, EdgePanSpeed, + FileCard, + FileRootReference, FocusActivation, HomeAccentColors, HomeAccentPresetId, @@ -198,6 +200,7 @@ export class SettingsStore { || !("persistMaterials" in source) || !("canvasRegions" in source) || !("stickyNotes" in source) + || !("fileCards" in source) || !("apiProfiles" in source) || source.canvasColor === "palette" || source.settingsVersion !== SETTINGS_VERSION; @@ -412,6 +415,7 @@ function createDefaults(systemLocale: string, platform: string): AppSettings { canvasRegions: [], stickyNotes: [], pluginCanvas: [], + fileCards: [], browserCanvas: null, browserAgentAccess: true, browserShowAgentPresence: true, @@ -532,6 +536,7 @@ export function normalizeSettings( homeGridSize ); const pluginCanvas = normalizePluginCanvas(source.pluginCanvas, fallback.pluginCanvas ?? []); + const fileCards = normalizeFileCards(source.fileCards, fallback.fileCards ?? []); const canvasRegions = normalizeCanvasRegions(source.canvasRegions, fallback.canvasRegions ?? []); const stickyNotes = normalizeStickyNotes(source.stickyNotes, fallback.stickyNotes ?? []); const browserCanvas = normalizeBrowserCanvas(source.browserCanvas, fallback.browserCanvas ?? null); @@ -693,6 +698,7 @@ export function normalizeSettings( canvasRegions, stickyNotes, pluginCanvas, + fileCards, browserCanvas, browserAgentAccess: typeof source.browserAgentAccess === "boolean" ? source.browserAgentAccess @@ -985,7 +991,7 @@ function normalizePluginCanvas(candidate: unknown, fallback: readonly PluginCanv title: source.title.trim().slice(0, 80), position: { x: source.position.x, y: source.position.y }, size: { - width: clamp(source.size.width, 240, 1_600), + width: clamp(source.size.width, 128, 1_600), height: clamp(source.size.height, 140, 1_100) } }); @@ -994,6 +1000,86 @@ function normalizePluginCanvas(candidate: unknown, fallback: readonly PluginCanv return instances; } +const MAX_FILE_CARDS = 64; +const MAX_FILE_CARD_EXPANDED_FOLDERS = 256; +const MAX_FILE_CARD_FOLDER_PATH = 4_096; +const MAX_FILE_CARD_RELATIVE_PATH = 1_024; + +function normalizeFileRootReference(candidate: unknown): FileRootReference | null { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return null; + const source = candidate as { rootType?: unknown; sessionId?: unknown; folderPath?: unknown }; + if (source.rootType === "folder") { + // A folder root may retain its chosen canonical path so restore can + // re-register it; it is validated and capped like the card-level path. + const folderPath = typeof source.folderPath === "string" + && source.folderPath.length > 0 + && source.folderPath.length <= MAX_FILE_CARD_FOLDER_PATH + ? source.folderPath + : undefined; + return folderPath ? { rootType: "folder", folderPath } : { rootType: "folder" }; + } + if (source.rootType !== "session") return null; + return typeof source.sessionId === "string" && source.sessionId.length > 0 && source.sessionId.length <= 80 + ? { rootType: "session", sessionId: source.sessionId } + : null; +} + +// Invalid cards are dropped rather than repaired: a card whose root or bounds +// no longer validate must not silently point at an unrelated location. +export function normalizeFileCards( + candidate: unknown, + fallback: readonly FileCard[] = [] +): FileCard[] { + if (!Array.isArray(candidate)) return fallback.map((card) => structuredClone(card)); + + const cards: FileCard[] = []; + const ids = new Set(); + for (const value of candidate.slice(0, MAX_FILE_CARDS)) { + if (!value || typeof value !== "object" || Array.isArray(value)) continue; + const source = value as Partial; + if (!isInstanceId(source.id) || ids.has(source.id)) continue; + const root = normalizeFileRootReference(source.root); + if (!root) continue; + if (!isFinitePoint(source.position) || !isFiniteSize(source.size)) continue; + const label = typeof source.label === "string" && source.label.trim().length > 0 + ? source.label.trim().slice(0, 80) + : null; + const folderPath = root.rootType === "folder" + && typeof source.folderPath === "string" + && source.folderPath.length > 0 + && source.folderPath.length <= MAX_FILE_CARD_FOLDER_PATH + ? source.folderPath + : null; + const activeFile = typeof source.activeFile === "string" + && source.activeFile.length > 0 + && source.activeFile.length <= MAX_FILE_CARD_RELATIVE_PATH + ? source.activeFile + : null; + const expandedFolders = Array.isArray(source.expandedFolders) + ? source.expandedFolders + .filter((entry): entry is string => typeof entry === "string" + && entry.length > 0 + && entry.length <= MAX_FILE_CARD_RELATIVE_PATH) + .slice(0, MAX_FILE_CARD_EXPANDED_FOLDERS) + : []; + cards.push({ + id: source.id, + root, + label, + folderPath, + activeFile, + expandedFolders, + position: { x: source.position.x, y: source.position.y }, + size: { + width: clamp(source.size.width, 240, 1_600), + height: clamp(source.size.height, 140, 1_100) + } + }); + ids.add(source.id); + } + return cards; +} + function normalizeCanvasRegions( candidate: unknown, fallback: readonly CanvasRegion[] = [] diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 157be296..2b70689d 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -253,6 +253,7 @@ export class TerminalManager { private baseProtectionOn: () => boolean = () => false; // The model and effort of a card whose first launch runs before the card is registered (create, restore). private readonly startingModels = new Map(); + private readonly initialCodexInputs = new Map(); private quitting = false; private readonly quitReleases: Promise[] = []; // Every PTY started here whose exit has not been reported yet, closed cards included, with that exit. @@ -634,6 +635,18 @@ export class TerminalManager { throw new Error("Result capture requires a Codex or OpenCode session."); } assertDirectory(request.cwd); + if (request.initialPrompt !== undefined || request.initialImagePath !== undefined) { + if (request.provider !== "codex" || request.resumeThreadId !== undefined + || typeof request.initialPrompt !== "string" || !request.initialPrompt.trim() + || request.initialPrompt.length > 20_000 || typeof request.initialImagePath !== "string") { + throw new Error("A new Codex chat needs one initial prompt and image."); + } + const imagePath = realpathSync(request.initialImagePath); + if (!isPathInside(realpathSync(request.cwd), imagePath) || !statSync(imagePath).isFile()) { + throw new Error("Initial Codex image must be a file in the project folder."); + } + request = { ...request, initialImagePath: imagePath }; + } const role = request.role ?? "agent"; if (request.parentSessionId !== undefined && !this.sessions.has(request.parentSessionId)) { @@ -678,6 +691,9 @@ export class TerminalManager { // With launch options, an environment or a launch policy the plugins answer first; the card waits and launches when they do. const contributed = (Boolean(launchOptions) || Boolean(environmentChoice) || this.policyApplies(request.provider)) && !awaitMeasuredGrid; this.startingModels.set(id, modelChoice); + if (request.initialPrompt && request.initialImagePath) { + this.initialCodexInputs.set(id, { prompt: request.initialPrompt, imagePath: request.initialImagePath }); + } if (control.ownerPluginId !== undefined) this.startingOwners.set(id, control.ownerPluginId); let launched: ReturnType | { process: null; agentBrowser: null; agentRuntime: null; agentOrchestration: null; failure: null }; try { @@ -1143,6 +1159,7 @@ export class TerminalManager { } this.hiddenSinceOffset.delete(id); this.launchContexts.delete(id); + this.initialCodexInputs.delete(id); this.redaction.clear(`session:${id}`); this.releaseIsolation(id); session.launchToken += 1; @@ -1750,8 +1767,13 @@ export class TerminalManager { ...(agentRuntime?.decisions === true && this.baseProtectionOn() && !environmentWrapped ? { shellGuarded: true } : {}), ...(isolated ? { isolated: true } : {}), cwd, + ...(this.initialCodexInputs.get(id) ? { + initialPrompt: this.initialCodexInputs.get(id)!.prompt, + initialImagePath: this.initialCodexInputs.get(id)!.imagePath + } : {}), ...this.launchModelOf(id) }); + this.initialCodexInputs.delete(id); const session = this.sessions.get(id); if (session) setAutoDowngraded(session.metadata, profile === "auto" && contribution?.thirdPartyModel === true); // A plugin may add to the person's environment, never replace what the core sets for this launch. diff --git a/src/main/services/UsageHistoryService.ts b/src/main/services/UsageHistoryService.ts new file mode 100644 index 00000000..275bccf5 --- /dev/null +++ b/src/main/services/UsageHistoryService.ts @@ -0,0 +1,654 @@ +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { copyFile, open, rename, unlink, type FileHandle } from "node:fs/promises"; +import { join } from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import type { LimitProviderId, LimitsSnapshot } from "../../shared/contracts.ts"; +import type { HistorySample, UsageCollectionHealth, UsageEvent, UsageHistory } from "../../shared/usageHistory.ts"; +import { sanitizeCollectorState, type CollectorState } from "./LocalUsageCollector.ts"; + +const FILE_NAME = "usage-history.json"; +const FORMAT = "canvastty-usage-history"; +// Initial upstream disk format: prototype envelopes are deliberately unsupported. +const STORE_VERSION = 2; +const RETENTION_MS = 30 * 86_400_000; +const COLLECTION_INTERVAL_MS = 60_000; +const INITIAL_DELAY_MS = 5_000; +const DISPOSE_WAIT_MS = 3_000; +const MAX_EVENTS = 500_000; +const MAX_NOTICES = 10; +/** One entry per run: 30 days of 60 s runs fit. */ +const MAX_HEALTH = 50_000; +const QUOTA_PROVIDERS: readonly LimitProviderId[] = ["codex", "claude"]; +const PROVIDER_LABELS: Record = { codex: "Codex", claude: "Claude" }; +const STATIC_COVERAGE = [ + "Quota samples: Codex and Claude only, every 60 s while CanvasTTY runs; cached, stale or unavailable limit results are never recorded as new observations.", + "Account scope: Codex uses a fingerprint of the local login's account ID; Claude uses a fingerprint of the current OAuth credential, so every token refresh starts a new scope and intervals across it are not compared.", + "Retention: 30 days of quota samples and usage records." +]; + +export interface UsageLimitsSource { + getProviders(providers: readonly LimitProviderId[]): Promise; +} + +export interface UsageCollector { + readonly state: CollectorState; + /** A result without `complete: true` is recorded as an incomplete run. */ + collect(now: number): Promise<{ events: UsageEvent[]; coverage: string[]; complete?: boolean; lost?: boolean }>; +} + +export interface UsageHistoryServiceOptions { + /** App userData only: history, collector cursors and notices share one atomically replaced file. */ + directory: string; + limits: UsageLimitsSource; + createCollector(state: CollectorState): UsageCollector; + now?: () => number; + intervalMs?: number; + initialDelayMs?: number; +} + +interface Notice { + at: number; + text: string; +} + +interface StoredHistory { + format: typeof FORMAT; + version: typeof STORE_VERSION; + history: UsageHistory; + collector: CollectorState; + notices: Notice[]; +} + +/** + * Collects quota samples and local usage records on its own timer, independent of any + * renderer. One collection runs at a time; each collector run works on a draft of the + * cursors, which are committed together with the records they produced, so the saved + * file never holds cursors ahead of its history. + */ +export class UsageHistoryService { + readonly filePath: string; + private readonly directory: string; + private readonly limits: UsageLimitsSource; + private readonly createCollector: (state: CollectorState) => UsageCollector; + private readonly now: () => number; + private readonly intervalMs: number; + private readonly initialDelayMs: number; + private startedAt = 0; + private collectedAt = 0; + private samples: HistorySample[] = []; + private events: UsageEvent[] = []; + /** Local collection health of every run, failed ones included; the report never covers time before the first. */ + private health: UsageCollectionHealth[] = []; + private readonly eventIndex = new Map(); + private eventsChanged = false; + private readonly lastSampleAt = new Map(); + private collectorState: CollectorState = { version: 2, files: {}, hermes: {} }; + private collectorCoverage: string[] = []; + private runCoverage: string[] = []; + private providerStatus: string[] = []; + private notices: Notice[] = []; + private loadError: string | null = null; + private collectError: string | null = null; + private saveError: string | null = null; + private writable = true; + private dirty = false; + private running: Promise | null = null; + private writing: Promise = Promise.resolve(); + private timer: NodeJS.Timeout | null = null; + private started = false; + private disposing: Promise | null = null; + // Set after the final save: a collection still in flight is discarded, not merged. + private closed = false; + + constructor(options: UsageHistoryServiceOptions) { + this.directory = options.directory; + this.filePath = join(options.directory, FILE_NAME); + this.limits = options.limits; + this.createCollector = options.createCollector; + this.now = options.now ?? Date.now; + this.intervalMs = options.intervalMs ?? COLLECTION_INTERVAL_MS; + this.initialDelayMs = options.initialDelayMs ?? INITIAL_DELAY_MS; + } + + async load(): Promise { + const now = this.now(); + this.startedAt = now; + let raw: string; + let handle: FileHandle | undefined; + try { + // O_NOFOLLOW: a planted symlink is neither read nor, later, written through. + handle = await open(this.filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + if (!(await handle.stat()).isFile()) throw Object.assign(new Error("not a file"), { code: "ENOTFILE" }); + raw = await handle.readFile("utf8"); + } catch (error) { + if (errorCode(error) === "ENOENT") return; + this.block(`Usage history file could not be read (${errorCode(error)}); it was left untouched and new history is not being saved.`); + return; + } finally { + await handle?.close().catch(() => undefined); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + await this.quarantine("invalid JSON", now); + return; + } + if (isRecord(parsed) && typeof parsed.format === "string" && (parsed.format !== FORMAT || parsed.version !== STORE_VERSION)) { + this.block(`Usage history file has an unsupported format (${String(parsed.version).slice(0, 20)}); it was left untouched and new history is not being saved.`); + return; + } + if (isRecord(parsed) && isRecord(parsed.collector) && parsed.collector.version !== 2) { + this.block("Usage history has an unsupported collector version; it was left untouched and new history is not being saved."); + return; + } + const restored = isRecord(parsed) ? restore(parsed) : null; + if (!restored) { + await this.quarantine("invalid structure", now); + return; + } + + if (restored.invalid > 0) { + // The cleaned history will replace the file, so the original is copied aside first. + const name = preservedName(now); + try { + await copyFile(this.filePath, join(this.directory, name), constants.COPYFILE_EXCL); + } catch (error) { + this.block(`Usage history contains ${restored.invalid} invalid records and could not be preserved (${errorCode(error)}); it was left untouched and new history is not being saved.`); + return; + } + const text = `${restored.invalid} invalid usage history records were ignored; the original file was preserved as ${name}.`; + this.loadError = text; + restored.notices.push({ at: now, text }); + this.dirty = true; + } + const { history } = restored; + this.startedAt = history.startedAt; + this.collectedAt = history.collectedAt; + this.collectorState = restored.collector; + this.notices = restored.notices.slice(-MAX_NOTICES); + const noticeTexts = new Set(this.notices.map((notice) => notice.text)); + this.collectorCoverage = history.coverage.filter((line) => !STATIC_COVERAGE.includes(line) && !noticeTexts.has(line)); + this.providerStatus = history.providerStatus; + this.samples = history.samples; + this.health = (history.health ?? []).slice(-MAX_HEALTH); + for (const sample of this.samples) this.rememberSample(sample); + for (const event of history.events) this.eventIndex.set(event.id, event); + this.eventsChanged = true; + this.prune(now); + this.rebuildEvents(); + } + + get(): UsageHistory { + return structuredClone(this.snapshot()); + } + + /** Runs one collection now, or joins the one already running. */ + async collect(): Promise { + if (!this.closed) await this.serialized(); + return this.get(); + } + + start(): void { + if (this.started || this.disposing) return; + this.started = true; + this.schedule(this.initialDelayMs); + } + + dispose(): Promise { + if (this.disposing) return this.disposing; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + this.disposing = (async () => { + const running = this.running; + if (running) { + let timeout: NodeJS.Timeout | undefined; + await Promise.race([running, new Promise((resolve) => { timeout = setTimeout(resolve, DISPOSE_WAIT_MS); })]); + clearTimeout(timeout); + } + this.closed = true; + await this.save(); + })(); + return this.disposing; + } + + private schedule(delay: number): void { + if (this.disposing) return; + this.timer = setTimeout(() => { + this.timer = null; + const began = Date.now(); + void this.serialized().then(() => this.schedule(Math.max(0, this.intervalMs - (Date.now() - began)))); + }, delay); + this.timer.unref?.(); + } + + private serialized(): Promise { + if (!this.running) { + this.running = this.run().finally(() => { + this.running = null; + }); + } + return this.running; + } + + private async run(): Promise { + const now = this.now(); + let health: UsageCollectionHealth = { at: now, complete: false }; + try { + const draft = structuredClone(this.collectorState); + const [limits, local] = await Promise.allSettled([ + this.limits.getProviders(QUOTA_PROVIDERS), + (async () => { + const collector = this.createCollector(draft); + const result = await collector.collect(now); + return { result, state: collector.state }; + })() + ]); + if (this.closed) return; + + this.runCoverage = []; + this.providerStatus = limits.status === "fulfilled" + ? this.recordLimits(limits.value, now) + : ["Quota limits: request failed; no quota samples were recorded in this run."]; + if (local.status === "fulfilled") { + this.collectorState = local.value.state; + const { result } = local.value; + this.collectorCoverage = result.coverage.filter((line) => typeof line === "string"); + // Rejected records are gone: the collector's cursors have already moved past them. + const rejected = this.mergeEvents(result.events, now); + const lost = result.lost === true || rejected > 0; + health = lost ? { at: now, complete: false, lost: true } : { at: now, complete: result.complete === true }; + this.collectError = null; + this.dirty = true; + } else { + this.collectError = "Local usage collection failed in the last run; its cursors were not advanced and the next run retries."; + } + this.collectedAt = now; + } catch { + this.collectError = "Usage history collection failed unexpectedly in the last run."; + } + if (this.closed) return; + this.health.push(health); + if (this.health.length > MAX_HEALTH) this.health = this.health.slice(-MAX_HEALTH); + this.dirty = true; + try { + this.prune(now); + this.rebuildEvents(); + } catch { + this.collectError = "Usage history collection failed unexpectedly in the last run."; + } + await this.save(); + } + + private recordLimits(snapshot: LimitsSnapshot, now: number): string[] { + const status: string[] = []; + for (const provider of QUOTA_PROVIDERS) { + const label = PROVIDER_LABELS[provider] ?? provider; + const value = snapshot.providers.find((candidate) => candidate.provider === provider); + if (!value) { + status.push(`${label}: no limits result; no quota sample.`); + continue; + } + if (value.state === "unavailable") { + status.push(`${label}: unavailable (${value.reason}); no quota sample.`); + continue; + } + if (value.state === "stale") { + status.push(`${label}: stale since ${iso(value.failedAt)} (${value.reason}); it repeats the ${iso(value.fetchedAt)} observation, so no quota sample.`); + continue; + } + const scope = value.accountScope ?? null; + const candidates = value.windows.flatMap((window) => { + const candidate = sample({ + provider, scope, windowId: window.id, at: value.fetchedAt, reset: window.resetsAt, percent: window.usedPercent + }); + return candidate && candidate.at <= now + 60_000 && candidate.at >= now - RETENTION_MS ? [candidate] : []; + }); + let added = 0; + for (const candidate of candidates) { + // A cached limits result repeats an observation already recorded: it is not a new one. + const last = this.lastSampleAt.get(sampleKey(candidate)); + if (last !== undefined && candidate.at <= last) continue; + this.samples.push(candidate); + this.rememberSample(candidate); + added += 1; + } + if (added > 0) this.dirty = true; + if (candidates.length === 0) { + status.push(`${label}: no quota window reported a usage percentage; no quota sample.`); + } else if (added === 0) { + status.push(`${label}: the ${iso(value.fetchedAt)} observation (cached) is already recorded; no new sample.`); + } else { + status.push(scope + ? `${label}: ${added} quota window${added === 1 ? "" : "s"} sampled.` + : `${label}: ${added} quota window${added === 1 ? "" : "s"} sampled with an unknown account (changed or unreadable login); these samples are not compared with others.`); + } + } + return status; + } + + /** Returns rejected records, whose consumed source positions make them lost. */ + private mergeEvents(candidates: readonly unknown[], now: number): number { + let invalid = 0; + for (const candidate of candidates) { + const event = usageEvent(candidate); + if (!event) { + invalid += 1; + continue; + } + if (event.to < now - RETENTION_MS) continue; + const existing = this.eventIndex.get(event.id); + // Streaming updates repeat one message ID with growing usage: keep the maxima. + const merged = existing ? { + ...existing, + from: Math.min(existing.from, event.from), + to: Math.max(existing.to, event.to), + input: Math.max(existing.input, event.input), + output: Math.max(existing.output, event.output), + cached: Math.max(existing.cached, event.cached) + } : event; + if (existing && sameUsage(existing, merged)) continue; + this.eventIndex.set(event.id, merged); + this.eventsChanged = true; + } + if (invalid > 0) this.runCoverage.push(`${invalid} invalid usage records from local logs were ignored in the last run.`); + return invalid; + } + + private prune(now: number): void { + const cutoff = now - RETENTION_MS; + const samples = this.samples.filter((candidate) => candidate.at >= cutoff); + if (samples.length !== this.samples.length) this.dirty = true; + this.samples = samples; + for (const [id, event] of this.eventIndex) { + if (event.to < cutoff) { + this.eventIndex.delete(id); + this.eventsChanged = true; + } + } + const health = this.health.filter((entry) => entry.at >= cutoff); + if (health.length !== this.health.length) this.dirty = true; + this.health = health; + if (this.eventIndex.size > MAX_EVENTS) { + const excess = [...this.eventIndex.values()].sort(byTime).slice(0, this.eventIndex.size - MAX_EVENTS); + for (const event of excess) this.eventIndex.delete(event.id); + // Time up to the newest dropped record is no longer fully recorded. + const dropped = excess[excess.length - 1].to; + this.health = this.health.filter((entry) => entry.at > dropped); + this.runCoverage.push(`${excess.length} oldest usage records were dropped to keep the history file bounded.`); + this.eventsChanged = true; + } + this.notices = this.notices.filter((notice) => notice.at >= cutoff); + } + + private rebuildEvents(): void { + if (!this.eventsChanged) return; + this.events = [...this.eventIndex.values()].sort(byTime); + this.eventsChanged = false; + this.dirty = true; + } + + private rememberSample(candidate: HistorySample): void { + const key = sampleKey(candidate); + this.lastSampleAt.set(key, Math.max(this.lastSampleAt.get(key) ?? candidate.at, candidate.at)); + } + + private snapshot(): UsageHistory { + return { + version: 1, + startedAt: this.startedAt, + collectedAt: this.collectedAt, + samples: this.samples, + events: this.events, + coverage: this.coverage(), + providerStatus: this.providerStatus, + error: this.errorText(), + health: this.health + }; + } + + private coverage(): string[] { + return [...STATIC_COVERAGE, ...this.notices.map((notice) => notice.text), ...this.collectorCoverage, ...this.runCoverage]; + } + + private errorText(): string | null { + return [this.loadError, this.collectError, this.saveError].filter(Boolean).join(" ") || null; + } + + private block(text: string): void { + this.writable = false; + this.loadError = text; + } + + /** Moves an unreadable file aside (never deletes or overwrites it) and restarts history. */ + private async quarantine(reason: string, now: number): Promise { + const name = preservedName(now); + try { + await rename(this.filePath, join(this.directory, name)); + } catch (error) { + this.block(`Usage history file is unreadable (${reason}) and could not be preserved (${errorCode(error)}); it was left untouched and new history is not being saved.`); + return; + } + const text = `Usage history was unreadable (${reason}); it was preserved as ${name} and history restarted at ${iso(now)}.`; + this.loadError = text; + this.notices.push({ at: now, text }); + this.dirty = true; + } + + private save(): Promise { + this.writing = this.writing.then(() => this.write()); + return this.writing; + } + + private async write(): Promise { + if (!this.writable || !this.dirty) return; + const stored: StoredHistory = { + format: FORMAT, + version: STORE_VERSION, + // Errors describe this app run only and are recomputed after the next start. + history: { ...this.snapshot(), error: null }, + collector: this.collectorState, + notices: this.notices + }; + const body = JSON.stringify(stored); + const temporary = join(this.directory, `.${FILE_NAME}.${process.pid}.${randomUUID()}.tmp`); + let handle: FileHandle | undefined; + try { + handle = await open(temporary, "wx", 0o600); + await handle.writeFile(body, "utf8"); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporary, this.filePath); + this.dirty = false; + this.saveError = null; + } catch (error) { + await handle?.close().catch(() => undefined); + await unlink(temporary).catch(() => undefined); + this.saveError = `Usage history could not be saved (${errorCode(error)}); collection continues in memory and the last saved copy is unchanged.`; + return; + } + await syncDirectory(this.directory); + } +} + +interface Restored { + history: UsageHistory; + collector: CollectorState; + notices: Notice[]; + invalid: number; +} + +function restore(value: Record): Restored | null { + const history = value.history; + if ( + value.format !== FORMAT + || value.version !== STORE_VERSION + || !isRecord(history) + || history.version !== 1 + || !isTime(history.startedAt) + || !isTime(history.collectedAt) + || !Array.isArray(history.samples) + || !Array.isArray(history.events) + || !Array.isArray(history.health) + || !isStringArray(history.coverage) + || !isStringArray(history.providerStatus) + || !isRecord(value.collector) + || (value.notices !== undefined && !Array.isArray(value.notices)) + ) return null; + + let invalid = 0; + const keep = (candidate: T | null): candidate is T => { + if (candidate === null) invalid += 1; + return candidate !== null; + }; + const samples = history.samples.map(sample).filter(keep); + const events = history.events.map(usageEvent).filter(keep); + const health = history.health + .map(collectionHealth).filter(keep).sort((left, right) => left.at - right.at); + const notices = ((value.notices ?? []) as unknown[]).map(notice).filter(keep); + const collector = collectorState(value.collector); + if (!collector) return null; + invalid += collector.invalid; + return { + history: { + version: 1, + startedAt: history.startedAt, + collectedAt: history.collectedAt, + samples, + events, + coverage: history.coverage, + providerStatus: history.providerStatus, + error: null, + health + }, + collector: collector.state, + notices, + invalid + }; +} + +/** + * The collector's own sanitizer is the single schema: only its known accounting fields survive. + * Every cursor table entry or top-level field it drops or alters (including unknown, possibly + * secret-bearing fields) counts as invalid, so the original file is preserved before rewriting. + */ +function collectorState(value: Record): { state: CollectorState; invalid: number } | null { + if ((value.files !== undefined && !isRecord(value.files)) || (value.hermes !== undefined && !isRecord(value.hermes))) return null; + const state = sanitizeCollectorState(value); + let invalid = Object.keys(value).filter((key) => !["version", "files", "hermes"].includes(key)).length; + for (const table of ["files", "hermes"] as const) { + const kept: Record = state[table]; + for (const [key, entry] of Object.entries((value[table] ?? {}) as Record)) { + if (!Object.hasOwn(kept, key) || !isDeepStrictEqual(compact(kept[key]), compact(entry))) invalid += 1; + } + } + return { state, invalid }; +} + +/** Drops empty-object fields (e.g. a reader state with nothing pending), which carry no data. */ +function compact(value: unknown): unknown { + if (!isRecord(value)) return value; + return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, compact(entry)]) + .filter(([, entry]) => !(isRecord(entry) && Object.keys(entry).length === 0))); +} + +function sample(value: unknown): HistorySample | null { + if (!isRecord(value)) return null; + const { provider, scope, windowId, at, reset, percent } = value; + if ( + !isText(provider, 64) + || !(scope === null || isText(scope, 128)) + || !isText(windowId, 200) + || !isTime(at) + || !(reset === null || isTime(reset)) + || typeof percent !== "number" || !Number.isFinite(percent) || percent < 0 || percent > 100 + ) return null; + return { provider, scope, windowId, at, reset, percent }; +} + +function usageEvent(value: unknown): UsageEvent | null { + if (!isRecord(value)) return null; + const { id, provider, app, profile, session, from, to, input, output, cached, timing } = value; + if ( + !isText(id, 200) + || !isText(provider, 64) + || !isText(app, 120) + || typeof profile !== "string" || profile.length > 256 + || typeof session !== "string" || session.length > 256 + || !isTime(from) || !isTime(to) || from > to + || !isCount(input) || !isCount(output) || !isCount(cached) + || (timing !== "event" && timing !== "poll-delta") + ) return null; + return { id, provider, app, profile, session, from, to, input, output, cached, timing }; +} + +function collectionHealth(value: unknown): UsageCollectionHealth | null { + if (!isRecord(value) || !isTime(value.at) || typeof value.complete !== "boolean") return null; + if (value.lost === undefined) return { at: value.at, complete: value.complete }; + return value.lost === true && value.complete === false ? { at: value.at, complete: false, lost: true } : null; +} + +function notice(value: unknown): Notice | null { + return isRecord(value) && isTime(value.at) && isText(value.text, 1_000) ? { at: value.at, text: value.text } : null; +} + +function sameUsage(left: UsageEvent, right: UsageEvent): boolean { + return left.from === right.from && left.to === right.to && left.input === right.input + && left.output === right.output && left.cached === right.cached; +} + +function sampleKey(candidate: HistorySample): string { + return `${candidate.provider}${candidate.windowId}`; +} + +function byTime(left: UsageEvent, right: UsageEvent): number { + return left.to - right.to || left.from - right.from || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0); +} + +function preservedName(now: number): string { + return `usage-history.corrupt-${new Date(now).toISOString().replace(/[:.]/g, "-")}-${randomUUID().slice(0, 8)}.json`; +} + +async function syncDirectory(directory: string): Promise { + let handle: FileHandle | undefined; + try { + handle = await open(directory, "r"); + await handle.sync(); + } catch { + // Directory fsync is best-effort durability; not every platform supports it. + } finally { + await handle?.close().catch(() => undefined); + } +} + +function iso(value: number): string { + return Number.isFinite(value) ? new Date(value).toISOString() : "an unknown time"; +} + +function errorCode(error: unknown): string { + const code = isRecord(error) ? error.code : undefined; + return typeof code === "string" && /^[A-Z0-9_]{1,32}$/.test(code) ? code : "unknown error"; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function isText(value: unknown, maxLength: number): value is string { + return typeof value === "string" && value.length > 0 && value.length <= maxLength; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((line) => typeof line === "string"); +} + +function isTime(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +function isCount(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} diff --git a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts index 66387516..a945496f 100644 --- a/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts +++ b/src/main/services/agent-runtime/ProviderRuntimeLaunch.ts @@ -562,7 +562,8 @@ function decisionHookCommands( command: commandWithEnvironment( [gate.command, ...gate.args, "pretool"], { ...(gate.env ?? {}), ...budgetEnvironment(decisionBudgetMs), [DECISION_FAIL_CLOSED_ENV]: "1" }, - platform + platform, + provider === "codex" ? "powershell" : "cmd" ), // Qwen hook timeouts are milliseconds; Claude's and Codex's are seconds. timeout: provider === "qwen" ? hookSeconds * 1_000 : hookSeconds @@ -645,7 +646,7 @@ function codexHookArgs( ): string[] { validateHelper(helper); const grouped = groupProviderHookMappings([ - ...(coreHooksEnabled ? lifecycleCommands(CODEX_HOOKS, helper, platform) : []), + ...(coreHooksEnabled ? lifecycleCommands(CODEX_HOOKS, helper, platform, HOOK_TIMEOUT_SECONDS, "powershell") : []), ...pluginCommands ]); const events = Object.entries(grouped).flatMap(([event, mappings]) => { @@ -1057,12 +1058,13 @@ function lifecycleCommands( mappings: readonly HookMapping[], helper: RuntimeHookHelperLaunch, platform: NodeJS.Platform, - timeout = HOOK_TIMEOUT_SECONDS + timeout = HOOK_TIMEOUT_SECONDS, + windowsShell: "cmd" | "powershell" = "cmd" ): ProviderHookCommand[] { return mappings.map((mapping) => ({ event: mapping.event, ...(mapping.matcher ? { matcher: mapping.matcher } : {}), - command: hookCommand(helper, mapping, platform), + command: hookCommand(helper, mapping, platform, windowsShell), timeout })); } @@ -1113,10 +1115,11 @@ function overlaySignature( function hookCommand( helper: RuntimeHookHelperLaunch, mapping: HookMapping, - platform: NodeJS.Platform + platform: NodeJS.Platform, + windowsShell: "cmd" | "powershell" = "cmd" ): string { const args = [helper.command, ...helper.args, mapping.state, mapping.event]; - return commandWithEnvironment(args, helper.env ?? {}, platform); + return commandWithEnvironment(args, helper.env ?? {}, platform, windowsShell); } function pluginHookCommand( @@ -1137,16 +1140,22 @@ function pluginHookCommand( event, providerEvent ]; - return commandWithEnvironment(args, runner.env ?? {}, platform); + return commandWithEnvironment(args, runner.env ?? {}, platform, provider === "codex" ? "powershell" : "cmd"); } function commandWithEnvironment( args: string[], environment: Readonly>, - platform: NodeJS.Platform + platform: NodeJS.Platform, + windowsShell: "cmd" | "powershell" = "cmd" ): string { const entries = Object.entries(environment); if (platform === "win32") { + if (windowsShell === "powershell") { + const prefix = entries.map(([key, value]) => `$env:${key}=${powershellQuote(value)}`).join("; "); + const command = `& ${args.map(powershellQuote).join(" ")}; exit $LASTEXITCODE`; + return `${prefix ? `${prefix}; ` : ""}${command}`; + } const prefix = entries.map(([key, value]) => ( `set "${key}=${value.replaceAll("%", "%%").replaceAll('"', '\\"')}"` )).join(" && "); @@ -1161,6 +1170,10 @@ function shellQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } +function powershellQuote(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + function windowsCommand(args: string[]): string { return args.map((value) => `"${value.replaceAll("%", "%%").replaceAll('"', '\\"')}"`).join(" "); } diff --git a/src/main/services/browser/BrowserElectronSmoke.ts b/src/main/services/browser/BrowserElectronSmoke.ts index b114cbf4..48ed324f 100644 --- a/src/main/services/browser/BrowserElectronSmoke.ts +++ b/src/main/services/browser/BrowserElectronSmoke.ts @@ -9,6 +9,7 @@ import { waitForBrowserSmokeWheelReady, runBrowserSmokeCleanup, requireBrowserSmokeScrollBaseline, + freezeEndedBeforeIdle, type BrowserSmokePageState, type BrowserSmokeWheelReadiness } from "./BrowserSmokeReadiness.ts"; @@ -498,7 +499,8 @@ async function assertOwnerWheelFreezesCrossingBrowser(service: BrowserService): globalThis.__canvasttyFreezeEvents.push({ active: event.active, generation: event.generation, - dataUrlLength: event.dataUrl?.length ?? 0 + dataUrlLength: event.dataUrl?.length ?? 0, + observedAt: Date.now() }); }); void 0; @@ -529,26 +531,27 @@ async function assertOwnerWheelFreezesCrossingBrowser(service: BrowserService): await waitUntil(async () => owner.webContents.executeJavaScript( "globalThis.__canvasttyFreezeEvents?.some((event) => event.active && event.dataUrlLength > 0) === true" )); + // The preceding IPC/capture wait can consume the original 250 ms wheel + // interval. Refresh it before testing the surface transition. + const sequenceRefreshedAt = Date.now(); + service.beginRendererWheelSequence({ clientX: 400, clientY: 300 }); service.setViewport({ ...initialViewport, x: 403, surface: "placeholder" }); await new Promise((resolve) => setTimeout(resolve, 80)); service.setViewport({ ...initialViewport, x: 403, surface: "native" }); await new Promise((resolve) => setTimeout(resolve, 80)); - const endedDuringPlaceholder = await owner.webContents.executeJavaScript(` - (() => { - const events = globalThis.__canvasttyFreezeEvents ?? []; - const activeIndex = events.findIndex((event) => event.active && event.dataUrlLength > 0); - return activeIndex >= 0 && events.slice(activeIndex + 1).some((event) => !event.active); - })() - `); + const freezeEvents = await owner.webContents.executeJavaScript( + "globalThis.__canvasttyFreezeEvents ?? []" + ) as Array<{ active: boolean; observedAt: number }>; + const endedDuringPlaceholder = freezeEndedBeforeIdle( + freezeEvents, sequenceRefreshedAt, BROWSER_CANVAS_WHEEL_IDLE_MS + ); if (endedDuringPlaceholder) { throw new Error("Native/placeholder transition ended the active canvas wheel sequence."); } + const idleDeadline = sequenceRefreshedAt + BROWSER_CANVAS_WHEEL_IDLE_MS; await waitUntil(async () => owner.webContents.executeJavaScript(` - (() => { - const events = globalThis.__canvasttyFreezeEvents ?? []; - const activeIndex = events.findIndex((event) => event.active && event.dataUrlLength > 0); - return activeIndex >= 0 && events.slice(activeIndex + 1).some((event) => !event.active); - })() + (globalThis.__canvasttyFreezeEvents ?? []).some((event) => + !event.active && event.observedAt >= ${idleDeadline}) `)); } finally { service.setViewport({ x: 0, y: 0, width: 820, height: 620, surface: "native", canvasScale: 1 }); diff --git a/src/main/services/browser/BrowserSmokeReadiness.ts b/src/main/services/browser/BrowserSmokeReadiness.ts index d4e15242..319c3e14 100644 --- a/src/main/services/browser/BrowserSmokeReadiness.ts +++ b/src/main/services/browser/BrowserSmokeReadiness.ts @@ -22,6 +22,17 @@ export function isBrowserSmokeWheelReady(state: BrowserSmokeWheelReadiness): boo && state.page.focused; } +/** A freeze release after the wheel idle deadline is expected, even if the renderer reports it late. */ +export function freezeEndedBeforeIdle( + events: readonly { active: boolean; observedAt: number }[], + sequenceRefreshedAt: number, + idleMs: number +): boolean { + return events.some((event) => !event.active + && event.observedAt >= sequenceRefreshedAt + && event.observedAt < sequenceRefreshedAt + idleMs); +} + type BrowserSmokeSchedule = (callback: () => void, delayMs: number) => () => void; const scheduleBrowserSmokeCheck: BrowserSmokeSchedule = (callback, delayMs) => { diff --git a/src/main/services/materials/HandoffService.ts b/src/main/services/materials/HandoffService.ts new file mode 100644 index 00000000..85abce3f --- /dev/null +++ b/src/main/services/materials/HandoffService.ts @@ -0,0 +1,748 @@ +import { constants } from "node:fs"; +import { copyFile, lstat, mkdir, readdir, realpath, rm, writeFile } from "node:fs/promises"; +import { isAbsolute, join, relative, sep } from "node:path"; +import type { + HandoffBlock, + HandoffDelivery, + HandoffDraft, + HandoffItem, + HandoffPreview, + HandoffPreviewResult, + HandoffResult, + HandoffWarning, + LocaleId, + MaterialFailure, + MaterialHandoff, + MaterialRemark, + ProviderId, + RemarkTarget, + SessionMetadata, + Size, + TerminalDataEvent +} from "../../../shared/contracts"; +import { IPC } from "../../../shared/contracts.ts"; +import { extensionForMime, materialType, HANDOFF_NOTE_LIMIT, handoffBlockFor } from "../../../shared/materials.ts"; +import { terminalFileQuotePath } from "../../../shared/terminalFileDrop.ts"; +import { codexComposerReady } from "../agent-control/AgentControlGateway.ts"; +import { + handoffPointerText, + handoffText, + HANDOFF_TEXT_LIMIT, + type HandoffImageMode, + type HandoffTextImage, + type HandoffTextInput, + type HandoffTextTarget, + terminalSafe +} from "./handoffText.ts"; +import { anchorRect, cropRect, type PixelRect } from "./imageRegions.ts"; +import { HANDOFF_REMARK_LIMIT, isId, MAX_SESSION_ID } from "./materialState.ts"; +import type { MaterialService, MaterialVersionFile } from "./MaterialService.ts"; +import { imageMarkers, pasteMarkers, SessionScreen, withoutBraille, type ScreenPort } from "./sessionScreen.ts"; + +const HANDOFF_FOLDER_LIMIT = 50; +const HANDOFF_PACKAGE_LIMIT = 512 * 1024 * 1024; +const HANDOFF_FOLDERS_BYTES_LIMIT = 1024 * 1024 * 1024; +const IMAGE_ATTACHMENT_LIMIT = 9; +const VISIBLE_TAIL_CHARS = 48; +const TURN_START_WINDOW_MS = 10 * 60 * 1000; +const RESTARTED = "The session restarted or exited during delivery."; +const PASTE_START = "\x1b[200~"; +const PASTE_END = "\x1b[201~"; + +interface HandoffTerminalPort extends ScreenPort { + listMetadata(): SessionMetadata[]; + launchPending(id: string): boolean; + deliverInput(id: string, data: string, waitMs?: number): Promise<{ delivered: true } | { delivered: false; reason: string }>; + pluginContext(id: string): { workingDirectory: string; environment: unknown | null } | null; +} + +export interface HandoffImageOps { + canDraw(mimeType: string, natural: Size): boolean; + marked(source: string, rect: PixelRect, natural: Size): Promise; + crop(source: string, rect: PixelRect, natural: Size): Promise; +} + +interface HandoffTiming { + attachMs: number; + pasteMs: number; + pollMs: number; +} + +interface HandoffServiceOptions { + materials: MaterialService; + terminals: HandoffTerminalPort; + images: HandoffImageOps; + root: string; + locale(): LocaleId; + now?(): number; + timing?: Partial; + packageLimit?: number; + foldersBytesLimit?: number; + onSent?(handoff: MaterialHandoff): void; +} + +interface PlannedFile { + name: string; + source: string; + byteSize: number; + operation: "copy" | "marked" | "crop"; + rect?: PixelRect; + natural?: Size; +} + +interface HandoffPlan { + id: string; + number: number; + folder: string; + session: SessionMetadata; + remarks: MaterialRemark[]; + items: HandoffItem[]; + files: PlannedFile[]; + text: HandoffTextInput; + resultsFolder: string | null; + warnings: HandoffWarning[]; + availableBytes: number; +} + +interface HandoffFolder { + path: string; + time: number; + bytes: number; +} + +type Checked = { ok: true; plan: HandoffPlan } | { ok: false; reason: HandoffBlock | MaterialFailure }; + +type Reserved = { ok: true; plan: HandoffPlan; screen: SessionScreen | null } | { ok: false; reason: HandoffBlock | MaterialFailure }; + +const DEFAULT_TIMING: HandoffTiming = { attachMs: 8_000, pasteMs: 6_000, pollMs: 120 }; + +export class HandoffService { + private readonly options: HandoffServiceOptions; + private readonly timing: HandoffTiming; + private readonly screens = new Map(); + private readonly sending = new Set(); + private readonly resultsFolders = new Set(); + private readonly submitting = new Map(); + private planning: Promise = Promise.resolve(); + + constructor(options: HandoffServiceOptions) { + this.options = options; + this.timing = { ...DEFAULT_TIMING, ...options.timing }; + for (const session of options.terminals.listMetadata()) this.trackScreen(session); + } + + grantResultsFolder(path: string): void { + this.resultsFolders.add(path); + } + + async preview(draft: unknown): Promise { + const checked = await this.check(draft, false); + if (!checked.ok) return { ok: false, reason: checked.reason }; + const { plan } = checked; + const text = this.compose(plan); + const preview: HandoffPreview = { + text, + files: plan.files.map((file) => file.name), + imageMode: plan.text.imageMode === "paths" ? "paths" : "attach", + images: plan.text.images.length, + warnings: plan.warnings + }; + return { ok: true, preview }; + } + + async send(draft: unknown): Promise { + const sessionId = isRecord(draft) && typeof draft.sessionId === "string" ? draft.sessionId : ""; + if (this.sending.has(sessionId)) return { ok: false, reason: "busy" }; + this.sending.add(sessionId); + try { + const reserved = await this.exclusive(() => this.prepare(draft, sessionId)); + if (!reserved.ok) return reserved; + const { plan, screen } = reserved; + let outcome: Partial; + try { + outcome = await this.deliver(plan, this.compose(plan), screen); + } catch (error) { + outcome = { state: "failed", error: error instanceof Error ? error.message : "Delivery failed." }; + } + const early = this.submitting.get(plan.id)?.turnStartedAt ?? null; + this.submitting.delete(plan.id); + if (outcome.state === "submitted" && early !== null) outcome.turnStartedAt = early; + this.options.materials.updateHandoffDelivery(plan.id, outcome); + if (outcome.state === "submitted") this.options.materials.markRemarksSent(plan.remarks.map((remark) => remark.id), plan.id); + const persisted = await this.options.materials.flush(true).then(() => true, () => false); + if (!persisted) { + outcome = { ...outcome, stateSaved: false }; + this.options.materials.updateHandoffDelivery(plan.id, outcome); + } + await this.prune().catch(() => undefined); + const recorded = this.options.materials.handoff(plan.id); + if (!recorded) return { ok: false, reason: "unavailable" }; + if (recorded.delivery.state === "submitted" || recorded.delivery.state === "pasted") this.options.onSent?.(recorded); + return { ok: true, handoff: recorded }; + } finally { + this.sending.delete(sessionId); + } + } + + observe(channel: string, payload: unknown): void { + if (channel === IPC.terminalData) { + const event = payload as TerminalDataEvent; + this.screens.get(event.id)?.feed(event); + return; + } + if (channel === IPC.terminalRemoved) { + if (isRecord(payload) && typeof payload.id === "string") this.dropScreen(payload.id); + return; + } + if (channel !== IPC.terminalSession || !isRecord(payload) || !isRecord(payload.session)) return; + const session = payload.session as unknown as SessionMetadata; + this.trackScreen(session); + for (const pending of this.submitting.values()) { + if (pending.sessionId === session.id && pending.turnStartedAt === null && session.status === "working") { + pending.turnStartedAt = this.now(); + } + } + const latest = this.options.materials.latestHandoff(session.id); + if (!latest || latest.delivery.state !== "submitted" || latest.delivery.sentAt === null) return; + if (latest.sessionStartedAt !== null && session.startedAt !== latest.sessionStartedAt) return; + const now = this.now(); + if (latest.delivery.turnStartedAt === null && session.status === "working" && now - latest.delivery.sentAt <= TURN_START_WINDOW_MS) { + this.options.materials.updateHandoffDelivery(latest.id, { turnStartedAt: now }); + } else if (latest.delivery.turnStartedAt !== null && latest.delivery.turnEndedAt === null + && (session.status === "idle" || session.status === "done" || session.status === "failed")) { + this.options.materials.updateHandoffDelivery(latest.id, { turnEndedAt: now }); + } + } + + dispose(): void { + for (const screen of this.screens.values()) screen.dispose(); + this.screens.clear(); + } + + private exclusive(task: () => Promise): Promise { + const run = this.planning.catch(() => undefined).then(task); + this.planning = run.catch(() => undefined); + return run; + } + + private async prepare(draft: unknown, sessionId: string): Promise { + const reserved = await this.reserve(draft, sessionId); + if (!reserved.ok) return reserved; + const { plan } = reserved; + const textBytes = Buffer.byteLength(`${handoffText(plan.text)}\n`, "utf8"); + const filesLimit = Math.min(this.packageLimit(), plan.availableBytes - textBytes); + let unwritten: "too-long" | "quota" | "unreadable" | null = null; + try { + if (await this.writePackage(plan, filesLimit)) { + await writeFile(join(plan.folder, "handoff.md"), `${handoffText(plan.text)}\n`, { encoding: "utf8", mode: 0o600 }); + await this.prunePackages(); + } else { + unwritten = filesLimit < this.packageLimit() ? "quota" : "too-long"; + } + } catch { + unwritten = "unreadable"; + } + if (unwritten) { + await rm(plan.folder, { recursive: true, force: true }).catch(() => undefined); + this.options.materials.updateHandoffDelivery(plan.id, { + state: "failed", + error: unwritten === "unreadable" ? "The handoff files could not be written." : "The handoff files would exceed the storage limit." + }); + return { ok: false, reason: unwritten }; + } + this.options.materials.updateHandoffDelivery(plan.id, { imagesExpected: plan.text.images.length }); + try { + await this.options.materials.flush(true); + } catch { + this.options.materials.updateHandoffDelivery(plan.id, { + state: "failed", error: "The handoff state could not be saved.", stateSaved: false + }); + return { ok: false, reason: "unavailable" }; + } + return reserved; + } + + private async reserve(draft: unknown, sessionId: string): Promise { + const checked = await this.check(draft, true); + if (!checked.ok) return checked; + const { plan } = checked; + const screen = this.screenFor(sessionId); + const blocked = await this.composerBlock(plan, screen); + if (blocked) return { ok: false, reason: blocked }; + const protectedPaths = this.protectedFolders(); + this.options.materials.recordHandoff({ + id: plan.id, + number: plan.number, + createdAt: this.now(), + sessionId: plan.session.id, + sessionTitle: plan.session.title, + provider: plan.session.provider, + remarkIds: plan.remarks.map((remark) => remark.id), + items: plan.items, + note: plan.text.note, + folder: plan.folder, + resultsFolder: plan.resultsFolder, + sessionStartedAt: plan.session.startedAt, + delivery: { + state: "sending", + imagesExpected: plan.text.images.length, + imagesAttached: 0, + sentAt: null, + turnStartedAt: null, + turnEndedAt: null, + note: null, + error: null, + stateSaved: true + } + }, this.options.materials.snapshot().handoffs.filter((handoff) => protectedPaths.has(handoff.folder)).map((handoff) => handoff.id)); + return { ok: true, plan, screen }; + } + + private async composerBlock(plan: HandoffPlan, screen: SessionScreen | null): Promise { + if (!screen) return null; + if (!composerReady(plan.session.provider, await screen.text())) return "composer-not-ready"; + if (plan.session.provider === "claude" && await screen.typedAfter("❯")) return "composer-not-ready"; + return null; + } + + private deliveryError(plan: HandoffPlan, screen: SessionScreen | null): string | null { + const session = this.options.terminals.listMetadata().find((candidate) => candidate.id === plan.session.id); + if (!session || session.exitCode !== null || session.startedAt !== plan.session.startedAt + || (screen !== null && this.screens.get(session.id) !== screen)) return RESTARTED; + const blocked = handoffBlockFor(session, this.options.terminals.launchPending(session.id)); + return blocked || this.options.terminals.pluginContext(session.id)?.environment + ? "The agent's session is no longer ready for delivery." + : null; + } + + private async check(draft: unknown, strict: boolean): Promise { + if (isRecord(draft) && Array.isArray(draft.remarkIds) && draft.remarkIds.length > HANDOFF_REMARK_LIMIT) { + return { ok: false, reason: "too-many-remarks" }; + } + const parsed = parseDraft(draft); + if (!parsed) return { ok: false, reason: isRecord(draft) && Array.isArray(draft.remarkIds) && draft.remarkIds.length === 0 ? "no-remarks" : "unavailable" }; + if (this.options.materials.handoff(parsed.id)) return { ok: false, reason: "already-sent" }; + if (parsed.resultsFolder !== null && !this.resultsFolders.has(parsed.resultsFolder)) return { ok: false, reason: "unavailable" }; + const session = this.options.terminals.listMetadata().find((candidate) => candidate.id === parsed.sessionId); + if (!session) return { ok: false, reason: "no-session" }; + const block = handoffBlockFor(session, this.options.terminals.launchPending(session.id)); + if (block) return { ok: false, reason: block }; + const context = this.options.terminals.pluginContext(session.id); + if (context?.environment) return { ok: false, reason: "remote-environment" }; + const remarks = parsed.remarkIds + .map((id) => this.options.materials.remark(id)) + .filter((remark): remark is MaterialRemark => remark !== null) + .sort((left, right) => left.number - right.number); + if (remarks.length === 0) return { ok: false, reason: "no-remarks" }; + if (remarks.some((remark) => remark.status !== "open" && remark.status !== "reopened")) return { ok: false, reason: "unavailable" }; + const editable: string[] = []; + for (const id of parsed.editableMaterialIds) { + const location = this.options.materials.material(id)?.location; + if (location) editable.push(location); + } + const id = parsed.id; + const folder = join(this.options.root, id); + const number = this.options.materials.nextHandoffNumber(); + const files: PlannedFile[] = []; + const images: HandoffTextImage[] = []; + const copies = new Map(); + const items = new Map(); + const textRemarks = []; + for (const remark of remarks) { + const target = await this.planTarget(remark.target, `${remark.number}`, folder, files, images, copies); + if (!target) return { ok: false, reason: "unavailable" }; + const reference = remark.reference + ? await this.planTarget(remark.reference, `${remark.number}-ref`, folder, files, images, copies, true) + : null; + if (remark.reference && !reference) return { ok: false, reason: "unavailable" }; + for (const anchorTarget of [remark.target, remark.reference]) { + if (!anchorTarget) continue; + const key = `${anchorTarget.materialId}:${anchorTarget.versionId}`; + if (!items.has(key)) { + items.set(key, { materialId: anchorTarget.materialId, versionId: anchorTarget.versionId, editable: false }); + } + } + textRemarks.push({ number: remark.number, text: remark.text, target, reference }); + } + for (const materialId of parsed.editableMaterialIds) { + const material = this.options.materials.material(materialId); + if (!material?.location) continue; + for (const item of items.values()) if (item.materialId === materialId) item.editable = true; + if (![...items.values()].some((item) => item.materialId === materialId)) { + items.set(`${materialId}:live`, { materialId, versionId: null, editable: true }); + } + } + if (packageBytes(files) > this.packageLimit()) return { ok: false, reason: "too-long" }; + const warnings: HandoffWarning[] = []; + if (session.status === "unavailable") warnings.push("status-unknown"); + const workingDirectory = context?.workingDirectory ?? session.cwd; + const realWorkingDirectory = await realpath(workingDirectory).catch(() => workingDirectory); + if (parsed.resultsFolder && !isInside(realWorkingDirectory, parsed.resultsFolder)) warnings.push("results-outside-workdir"); + const imageMode = imageModeFor(session.provider); + const text: HandoffTextInput = { + locale: this.options.locale(), + number, + folder, + remarks: textRemarks, + editable, + note: parsed.note, + resultsFolder: parsed.resultsFolder, + reportFile: parsed.resultsFolder ? join(parsed.resultsFolder, `canvastty-report-${number}.json`) : null, + imageMode, + images: images.slice(0, IMAGE_ATTACHMENT_LIMIT) + }; + if (strict && handoffPointerText(text, join(folder, "handoff.md")).length > HANDOFF_TEXT_LIMIT) { + return { ok: false, reason: "too-long" }; + } + const available = await this.remainingCapacity().catch(() => null); + if (!available) return { ok: false, reason: "unreadable" }; + if (available.folders >= HANDOFF_FOLDER_LIMIT || packageBytes(files) + Buffer.byteLength(`${handoffText(text)}\n`, "utf8") > available.bytes) { + return { ok: false, reason: "quota" }; + } + return { + ok: true, + plan: { + id, + number, + folder, + session, + remarks, + items: [...items.values()], + files, + text, + resultsFolder: parsed.resultsFolder, + warnings, + availableBytes: available.bytes + } + }; + } + + private async planTarget( + target: RemarkTarget, + prefix: string, + folder: string, + files: PlannedFile[], + images: HandoffTextImage[], + copies: Map, + reference = false + ): Promise { + const version = this.options.materials.versionFile(target.materialId, target.versionId); + if (!version) return null; + const stem = `${prefix}-${slug(version.name)}-v${version.number}`; + const file = copies.get(version.versionId) ?? `${stem}${extensionFor(version)}`; + if (!copies.has(version.versionId)) { + copies.set(version.versionId, file); + files.push({ name: file, source: version.path, byteSize: version.byteSize, operation: "copy" }); + } + let marked: string | null = null; + let crop: string | null = null; + const drawable = version.kind === "image" && version.natural !== null && target.anchor.kind !== "whole" + && this.options.images.canDraw(version.mimeType, version.natural); + if (drawable && !reference) { + marked = `${stem}-marked.png`; + files.push({ name: marked, source: version.path, byteSize: version.byteSize, operation: "marked", rect: anchorRect(target.anchor, version.natural!), natural: version.natural! }); + } + if (drawable) { + crop = `${stem}-crop.png`; + files.push({ name: crop, source: version.path, byteSize: version.byteSize, operation: "crop", rect: cropRect(target.anchor, version.natural!), natural: version.natural! }); + } + if (version.kind === "image") { + for (const name of reference ? [crop ?? file] : [marked ?? file, ...(crop ? [crop] : [])]) { + images.push({ name, path: join(folder, name) }); + } + } + return { + name: version.name, + versionNumber: version.number, + anchor: target.anchor, + natural: version.natural, + file, + marked, + crop, + location: version.location + }; + } + + private compose(plan: HandoffPlan): string { + const text = handoffText(plan.text); + return text.length <= HANDOFF_TEXT_LIMIT ? text : handoffPointerText(plan.text, join(plan.folder, "handoff.md")); + } + + private async writePackage(plan: HandoffPlan, limit: number): Promise { + await mkdir(plan.folder, { recursive: true, mode: 0o700 }); + const produced = new Set(); + let total = 0; + for (const file of plan.files) { + const target = join(plan.folder, file.name); + if (file.operation === "copy") { + await copyFile(file.source, target, constants.COPYFILE_FICLONE); + total += (await lstat(target)).size; + if (total > limit) return false; + produced.add(file.name); + continue; + } + const bytes = file.operation === "marked" + ? await this.options.images.marked(file.source, file.rect!, file.natural!) + : await this.options.images.crop(file.source, file.rect!, file.natural!); + if (!bytes) continue; + total += bytes.byteLength; + if (total > limit) return false; + await writeFile(target, bytes, { mode: 0o600 }); + produced.add(file.name); + } + plan.text.images = plan.text.images.filter((image) => produced.has(image.name)); + for (const remark of plan.text.remarks) { + for (const target of [remark.target, remark.reference]) { + if (!target) continue; + if (target.marked && !produced.has(target.marked)) target.marked = null; + if (target.crop && !produced.has(target.crop)) target.crop = null; + } + } + return true; + } + + private async deliver(plan: HandoffPlan, text: string, screen: SessionScreen | null): Promise> { + const id = plan.session.id; + const images = plan.text.images; + const failed = (reason: string): Partial => ({ state: "failed", error: reason }); + const paste = async (data: string): Promise => { + const error = this.deliveryError(plan, screen); + if (error) return error; + const written = await this.options.terminals.deliverInput(id, `${PASTE_START}${data}${PASTE_END}`, 0); + return written.delivered ? null : written.reason; + }; + if (!screen) { + const error = await paste(text); + return error ? failed(error) : { state: "pasted", note: "not-observed", sentAt: this.now(), imagesAttached: 0 }; + } + if (await this.composerBlock(plan, screen)) return failed("The agent's prompt changed before delivery."); + const baseline = await screen.text(); + const beforeImages = imageMarkers(baseline); + const header = text.split("\n")[0]; + let beforeText = baseline; + let attached = 0; + if (plan.text.imageMode === "codex") { + for (const image of images) { + const error = await paste(terminalFileQuotePath(terminalSafe(image.path), process.platform)); + if (error) return failed(error); + const expected = beforeImages + attached + 1; + if (await this.waitFor(screen, (current) => imageMarkers(current) >= expected, this.timing.attachMs)) attached += 1; + } + beforeText = await screen.text(); + const error = await paste(text); + if (error) return failed(error); + } else { + const error = await paste(text); + if (error) return failed(error); + await this.waitFor(screen, (current) => imageMarkers(current) >= beforeImages + images.length, this.timing.attachMs); + attached = Math.max(0, Math.min(images.length, imageMarkers(await screen.text()) - beforeImages)); + } + const tail = compact(text).slice(-VISIBLE_TAIL_CHARS); + const tailsBefore = occurrences(compact(withoutBraille(beforeText)), tail); + const pastesBefore = pasteMarkers(beforeText); + const imagesBefore = imageMarkers(beforeText); + const visible = await this.waitFor(screen, (current) => ( + current.includes(header) + || occurrences(compact(withoutBraille(current)), tail) > tailsBefore + || pasteMarkers(current) > pastesBefore + || imageMarkers(current) > imagesBefore + ), this.timing.pasteMs); + if (!visible) return { state: "pasted", note: "not-seen", sentAt: this.now(), imagesAttached: attached }; + const error = this.deliveryError(plan, screen); + if (error) return error === RESTARTED + ? failed(error) + : { state: "pasted", note: "enter-failed", sentAt: this.now(), imagesAttached: attached, error }; + this.submitting.set(plan.id, { sessionId: id, turnStartedAt: null }); + const submitted = await this.options.terminals.deliverInput(id, "\r", 0); + return submitted.delivered + ? { state: "submitted", sentAt: this.now(), imagesAttached: attached } + : { state: "pasted", note: "enter-failed", sentAt: this.now(), imagesAttached: attached, error: submitted.reason }; + } + + private async waitFor(screen: SessionScreen, predicate: (text: string) => boolean, timeoutMs: number): Promise { + const deadline = this.now() + timeoutMs; + for (;;) { + if (predicate(await screen.text())) return true; + if (this.now() >= deadline) return false; + await new Promise((resolve) => setTimeout(resolve, this.timing.pollMs)); + } + } + + private screenFor(id: string): SessionScreen | null { + const session = this.options.terminals.listMetadata().find((candidate) => candidate.id === id); + if (session) this.trackScreen(session); + return this.screens.get(id) ?? null; + } + + private trackScreen(session: SessionMetadata): void { + const mirrored = (session.provider === "claude" || session.provider === "codex") && session.exitCode === null; + if (mirrored && !this.screens.has(session.id)) this.screens.set(session.id, new SessionScreen(this.options.terminals, session.id)); + if (!mirrored) this.dropScreen(session.id); + } + + private dropScreen(id: string): void { + this.screens.get(id)?.dispose(); + this.screens.delete(id); + } + + prune(): Promise { + return this.exclusive(() => this.prunePackages()); + } + + private protectedFolders(): Set { + const protectedPaths = new Set(); + const sessions = new Map(this.options.terminals.listMetadata().map((session) => [session.id, session])); + const delivered = new Set(); + for (const handoff of this.options.materials.snapshot().handoffs.slice().reverse()) { + if (handoff.delivery.state === "sending") protectedPaths.add(handoff.folder); + if (handoff.delivery.state !== "pasted" && handoff.delivery.state !== "submitted") continue; + if (delivered.has(handoff.sessionId)) continue; + const session = sessions.get(handoff.sessionId); + if (!session || session.exitCode !== null || session.status === "done" || session.status === "failed" + || (handoff.sessionStartedAt !== null && handoff.sessionStartedAt !== session.startedAt)) continue; + delivered.add(handoff.sessionId); + if (handoff.delivery.state === "pasted" || handoff.delivery.turnEndedAt === null) protectedPaths.add(handoff.folder); + } + return protectedPaths; + } + + private async remainingCapacity(): Promise<{ folders: number; bytes: number }> { + const protectedPaths = this.protectedFolders(); + const folders = (await this.packageFolders()).filter((folder) => protectedPaths.has(folder.path)); + return { + folders: folders.length, + bytes: Math.max(0, this.foldersBytesLimit() - folders.reduce((sum, folder) => sum + folder.bytes, 0)) + }; + } + + private async packageFolders(): Promise { + const entries = await readdir(this.options.root).catch((error) => { + if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return []; + throw error; + }); + const folders = await Promise.all(entries.map(async (entry) => { + const path = join(this.options.root, entry); + const info = await lstat(path).catch(() => null); + if (!info?.isDirectory()) return null; + return { path, time: info.mtimeMs, bytes: await folderBytes(path).catch(() => this.foldersBytesLimit()) }; + })); + return folders.filter((folder): folder is HandoffFolder => folder !== null).sort((left, right) => right.time - left.time); + } + + private async prunePackages(): Promise { + if (this.options.materials.snapshot().loadError) return; + const protectedPaths = this.protectedFolders(); + const folders = await this.packageFolders(); + const protectedPackages = folders.filter((folder) => protectedPaths.has(folder.path)); + let kept = protectedPackages.length; + let bytes = protectedPackages.reduce((sum, folder) => sum + folder.bytes, 0); + let exhausted = false; + const stale = folders.filter((folder) => { + if (protectedPaths.has(folder.path)) return false; + if (exhausted || kept >= HANDOFF_FOLDER_LIMIT || (kept > 0 && bytes + folder.bytes > this.foldersBytesLimit())) { + exhausted = true; + return true; + } + kept += 1; + bytes += folder.bytes; + return false; + }); + await Promise.all(stale.map((folder) => rm(folder.path, { recursive: true, force: true }))); + } + + private foldersBytesLimit(): number { + return this.options.foldersBytesLimit ?? HANDOFF_FOLDERS_BYTES_LIMIT; + } + + private packageLimit(): number { + return this.options.packageLimit ?? HANDOFF_PACKAGE_LIMIT; + } + + private now(): number { + return this.options.now?.() ?? Date.now(); + } +} + +function packageBytes(files: readonly PlannedFile[]): number { + return files.reduce((total, file) => total + file.byteSize, 0); +} + +async function folderBytes(path: string): Promise { + let total = 0; + for (const entry of await readdir(path)) { + const info = await lstat(join(path, entry)).catch(() => null); + if (info?.isFile()) total += info.size; + } + return total; +} + +function compact(value: string): string { + return value.replace(/\s+/g, ""); +} + +function occurrences(text: string, part: string): number { + if (!part) return 0; + let count = 0; + for (let at = text.indexOf(part); at !== -1; at = text.indexOf(part, at + part.length)) count += 1; + return count; +} + +function composerReady(provider: SessionMetadata["provider"], screen: string): boolean { + if (provider === "codex") return codexComposerReady(withoutBraille(screen)); + if (provider !== "claude") return true; + const prompt = screen.split("\n").filter((line) => /^\s*❯/.test(line)).at(-1); + return prompt !== undefined && !/\[(?:Pasted text|Image) #\d+/.test(prompt); +} + +function parseDraft(value: unknown): HandoffDraft | null { + if (!isRecord(value) || !isId(value.id)) return null; + if (typeof value.sessionId !== "string" || value.sessionId.length === 0 || value.sessionId.length > MAX_SESSION_ID) return null; + if (!Array.isArray(value.remarkIds) || value.remarkIds.length === 0 || value.remarkIds.length > HANDOFF_REMARK_LIMIT) return null; + if (!value.remarkIds.every((id) => typeof id === "string")) return null; + const editable = Array.isArray(value.editableMaterialIds) ? value.editableMaterialIds.filter((id): id is string => typeof id === "string") : []; + if (typeof value.note !== "string" || value.note.length > HANDOFF_NOTE_LIMIT) return null; + const resultsFolder = value.resultsFolder === null || value.resultsFolder === undefined + ? null + : typeof value.resultsFolder === "string" && isAbsolute(value.resultsFolder) && !value.resultsFolder.includes("\0") + ? value.resultsFolder + : undefined; + if (resultsFolder === undefined) return null; + return { + id: value.id, + sessionId: value.sessionId, + remarkIds: [...new Set(value.remarkIds as string[])], + editableMaterialIds: [...new Set(editable)].slice(0, HANDOFF_REMARK_LIMIT), + note: value.note, + resultsFolder + }; +} + +function imageModeFor(provider: ProviderId): HandoffImageMode { + if (provider === "claude") return "claude"; + if (provider === "codex") return "codex"; + return "paths"; +} + +function extensionFor(version: MaterialVersionFile): string { + if (materialType(version.name).mimeType === version.mimeType) { + const dot = version.name.lastIndexOf("."); + if (dot > 0) return version.name.slice(dot).toLowerCase().replace(/[^a-z0-9.]/g, ""); + } + return extensionForMime(version.mimeType); +} + +function slug(name: string): string { + const dot = name.lastIndexOf("."); + const stem = dot > 0 ? name.slice(0, dot) : name; + const cleaned = stem.normalize("NFKD").replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, ""); + return (cleaned || "material").slice(0, 48); +} + +function isInside(root: string, candidate: string): boolean { + const path = relative(root, candidate); + return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path)); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} diff --git a/src/main/services/materials/MaterialService.ts b/src/main/services/materials/MaterialService.ts index 031009e4..8a8a1797 100644 --- a/src/main/services/materials/MaterialService.ts +++ b/src/main/services/materials/MaterialService.ts @@ -1,11 +1,14 @@ import { randomUUID } from "node:crypto"; -import { constants, type BigIntStats, type Stats } from "node:fs"; -import { lstat, mkdir, open, readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises"; +import { constants, type BigIntStats } from "node:fs"; +import { lstat, mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; import { basename, dirname, isAbsolute, join } from "node:path"; import type { CanvasMaterial, + HandoffDelivery, MaterialCreateResult, MaterialFailure, + MaterialHandoff, + MaterialKind, MaterialOrigin, MaterialRemark, MaterialResult, @@ -23,7 +26,7 @@ import type { RemarkTarget, SessionBounds, Size -} from "../../../shared/contracts.ts"; +} from "../../../shared/contracts"; import { clampSize, MATERIAL_LIMIT, @@ -38,8 +41,11 @@ import { } from "../../../shared/materials.ts"; import { streamFile, textResponse } from "../fileResponse.ts"; import { IMAGE_HEADER_BYTES, imageDimensions } from "./imageDimensions.ts"; +import { fileDigest, MaterialBlobError, MaterialBlobs } from "./MaterialBlobs.ts"; import { emptyMaterialState, + HANDOFF_LIMIT, + isId, MATERIAL_STATE_VERSION, normalizeAnchor, restoreMaterialState, @@ -49,10 +55,10 @@ import { type StoredVersion } from "./materialState.ts"; import { DirectoryWatchSet, nodeWatchFactory, type WatchFactory } from "./materialWatch.ts"; -import { fileDigest, MaterialBlobError, MaterialBlobs } from "./MaterialBlobs.ts"; const MAX_NAME = 255; const MATERIAL_CAPTURE_MAX_BYTES = 32 * 1024 * 1024; +const READ_FILE_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); const PERSIST_DELAY_MS = 250; const REFRESH_DELAY_MS = 150; const POLL_INTERVAL_MS = 10_000; @@ -62,7 +68,7 @@ const RESPONSE_HEADERS = { "x-content-type-options": "nosniff" }; -export interface MaterialServiceOptions { +interface MaterialServiceOptions { userDataPath: string; persist(): boolean; emit(snapshot: MaterialsSnapshot): void; @@ -72,13 +78,18 @@ export interface MaterialServiceOptions { storageLimitBytes?: number; } -interface LiveState { - state: MaterialState; - signature: string | null; - byteSize: number | null; - modifiedAt: number | null; - revision: number; - movedTo: string | null; +export interface MaterialVersionFile { + materialId: string; + versionId: string; + number: number; + name: string; + kind: CanvasMaterial["kind"]; + mimeType: string; + path: string; + byteSize: number; + natural: Size | null; + location: string | null; + origin: MaterialOrigin | null; } interface MaterialCaptureInput { @@ -90,14 +101,25 @@ interface MaterialCaptureInput { natural?: Size | null; } +interface LiveState { + state: MaterialState; + signature: string | null; + byteSize: number | null; + modifiedAt: number | null; + revision: number; + movedTo: string | null; +} + export class MaterialService { private readonly options: MaterialServiceOptions; + readonly handoffsPath: string; private readonly root: string; private readonly statePath: string; private readonly blobs: MaterialBlobs; private readonly materials = new Map(); private remarks: MaterialRemark[] = []; - private counters = { remark: 0 }; + private handoffs: MaterialHandoff[] = []; + private counters = { remark: 0, handoff: 0 }; private readonly live = new Map(); private readonly watchers: DirectoryWatchSet; private readonly pendingRefresh = new Set(); @@ -109,8 +131,8 @@ export class MaterialService { private refreshTimer: ReturnType | null = null; private pollTimer: ReturnType | null = null; private loading: Promise | null = null; - private disposing: Promise | null = null; private writable = false; + private disposing: Promise | null = null; private loadError: "unreadable" | undefined; private disposed = false; @@ -118,6 +140,7 @@ export class MaterialService { this.options = options; this.root = join(options.userDataPath, "materials"); this.statePath = join(this.root, "state.json"); + this.handoffsPath = join(this.root, "handoffs"); this.blobs = new MaterialBlobs(join(this.root, "versions")); this.storageLimit = options.storageLimitBytes ?? MATERIAL_STORAGE_LIMIT; this.watchers = new DirectoryWatchSet(options.watchFactory ?? nodeWatchFactory, (ids) => this.scheduleRefresh(ids)); @@ -135,7 +158,7 @@ export class MaterialService { state = restoreMaterialState(JSON.parse(await readFile(this.statePath, "utf8"))); } catch (error) { const newStore = Boolean(error && typeof error === "object" && "code" in error && error.code === "ENOENT") - && await this.blobs.usedBytes().then((bytes) => bytes === 0, () => false); + && await readdir(this.root).then((entries) => entries.length === 0, () => false); if (!newStore) { console.warn("CanvasTTY materials could not be loaded and are left on disk as they are.", error); this.loadError = "unreadable"; @@ -150,9 +173,11 @@ export class MaterialService { this.remarks = state.remarks .filter((remark) => this.materials.has(remark.target.materialId)) .map((remark) => remark.reference && !this.hasVersion(remark.reference.materialId, remark.reference.versionId) ? { ...remark, reference: null } : remark); + this.handoffs = state.handoffs; this.counters = state.counters; this.writable = true; - await this.collect(); + const collected = await this.collect(); + if (collected && !this.options.persist()) await rm(this.handoffsPath, { recursive: true, force: true }).catch(() => undefined); if (this.disposed) return; for (const material of this.materials.values()) { await this.refreshLive(material); @@ -176,6 +201,7 @@ export class MaterialService { ...(this.loadError ? { loadError: this.loadError } : {}), materials: [...this.materials.values()].map((material) => this.publicMaterial(material)), remarks: structuredClone(this.remarks), + handoffs: structuredClone(this.handoffs), storage: { usedBytes: this.usedBytes(), limitBytes: this.storageLimit } }; } @@ -184,7 +210,7 @@ export class MaterialService { return this.materials.get(id)?.path ?? null; } - addPaths(paths: readonly unknown[], point: unknown): Promise { + addPaths(paths: readonly unknown[], point: unknown, origin: MaterialOrigin | null = null, folder: string | null = null): Promise { return this.serial(async () => { const result: MaterialsAddResult = { added: [], existing: [], rejected: [] }; if (!this.writable) { @@ -195,9 +221,7 @@ export class MaterialService { } const fresh: StoredMaterial[] = []; const infos = new Map(); - if (paths.length > MATERIAL_LIMIT) { - result.rejected.push({ name: `+${paths.length - MATERIAL_LIMIT}`, reason: "limit" }); - } + if (paths.length > MATERIAL_LIMIT) result.rejected.push({ name: `+${paths.length - MATERIAL_LIMIT}`, reason: "limit" }); for (const candidate of paths.slice(0, MATERIAL_LIMIT)) { const name = typeof candidate === "string" ? displayName(candidate) : "file"; if (typeof candidate !== "string" || !isAbsolute(candidate) || candidate.includes("\0")) { @@ -213,6 +237,10 @@ export class MaterialService { result.rejected.push({ name, reason: "unreadable" }); continue; } + if (folder !== null && (resolved !== candidate || dirname(candidate) !== folder)) { + result.rejected.push({ name, reason: "unreadable" }); + continue; + } if (!info.isFile()) { result.rejected.push({ name, reason: "not-a-file" }); continue; @@ -227,18 +255,23 @@ export class MaterialService { continue; } const type = materialType(basename(resolved)); - const kind = type.kind === "image" ? "image" : "file"; - const natural = kind === "image" ? await readImageDimensions(resolved) : null; + let natural: Size | null = null; + try { + if (type.kind === "image") natural = await readImageDimensions(resolved); + } catch { + result.rejected.push({ name, reason: "unreadable" }); + continue; + } const material: StoredMaterial = { id: randomUUID(), - kind, + kind: type.kind, name, - mimeType: kind === "image" ? type.mimeType : "application/octet-stream", + mimeType: type.mimeType, position: { x: 0, y: 0 }, - size: materialCardSize(kind, natural), + size: materialCardSize(type.kind, natural), path: resolved, identity: fileIdentity(info), - origin: null, + origin: origin ? structuredClone(origin) : null, createdAt: this.now(), versions: [], nextVersion: 1 @@ -273,7 +306,7 @@ export class MaterialService { if (this.materials.size >= MATERIAL_LIMIT) return failure("material-limit"); let blob; try { - blob = await this.blobs.writeFromBytes(input.bytes, MATERIAL_CAPTURE_MAX_BYTES, await this.availableBytes()); + blob = await this.blobs.writeFromBytes(input.bytes, MATERIAL_CAPTURE_MAX_BYTES, await this.available()); } catch (error) { return failure(blobFailure(error)); } @@ -423,6 +456,115 @@ export class MaterialService { }); } + remark(id: string): MaterialRemark | null { + const remark = this.remarks.find((candidate) => candidate.id === id); + return remark ? structuredClone(remark) : null; + } + + material(id: string): CanvasMaterial | null { + const material = this.materials.get(id); + return material ? this.publicMaterial(material) : null; + } + + versionFile(materialId: string, versionId: string): MaterialVersionFile | null { + const material = this.materials.get(materialId); + const version = material?.versions.find((candidate) => candidate.id === versionId); + if (!material || !version) return null; + return { + materialId, + versionId, + number: version.number, + name: material.name, + kind: material.kind, + mimeType: version.mimeType, + path: this.blobs.pathOf(version.sha256), + byteSize: version.byteSize, + natural: version.natural, + location: material.path, + origin: material.origin ? structuredClone(material.origin) : null + }; + } + + nextHandoffNumber(): number { + return this.counters.handoff + 1; + } + + recordHandoff(handoff: MaterialHandoff, retainedIds: readonly string[] = []): void { + this.counters.handoff = Math.max(this.counters.handoff, handoff.number); + const retained = new Set([...retainedIds, handoff.id]); + const handoffs = [...this.handoffs, structuredClone(handoff)]; + let discard = Math.max(0, handoffs.length - HANDOFF_LIMIT); + this.handoffs = handoffs.filter((candidate) => { + if (discard === 0 || retained.has(candidate.id)) return true; + discard -= 1; + return false; + }); + this.changed(); + } + + updateHandoffDelivery(id: string, delivery: Partial): void { + const handoff = this.handoffs.find((candidate) => candidate.id === id); + if (!handoff) return; + handoff.delivery = { ...handoff.delivery, ...delivery }; + this.changed(); + } + + latestHandoff(sessionId: string): MaterialHandoff | null { + for (let index = this.handoffs.length - 1; index >= 0; index -= 1) { + const handoff = this.handoffs[index]; + if (handoff.sessionId !== sessionId || (handoff.delivery.state !== "submitted" && handoff.delivery.state !== "pasted")) continue; + return structuredClone(handoff); + } + return null; + } + + confirmDelivery(handoffId: string): void { + const handoff = this.handoffs.find((candidate) => candidate.id === handoffId); + if (!handoff || handoff.delivery.state !== "pasted") return; + handoff.delivery = { ...handoff.delivery, state: "submitted", note: null }; + const newer = new Set(this.handoffs.filter((candidate) => candidate.number > handoff.number).map((candidate) => candidate.id)); + const current = handoff.remarkIds.filter((id) => !newer.has(this.remarks.find((remark) => remark.id === id)?.handoffIds.at(-1) ?? "")); + this.markRemarksSent(current, handoff.id); + } + + handoff(id: string): MaterialHandoff | null { + const handoff = this.handoffs.find((candidate) => candidate.id === id); + return handoff ? structuredClone(handoff) : null; + } + + markRemarksSent(remarkIds: readonly string[], handoffId: string): void { + const now = this.now(); + for (const remark of this.remarks) { + if (!remarkIds.includes(remark.id)) continue; + if (remark.status === "accepted" || remark.status === "reported") continue; + remark.status = "sent"; + remark.report = null; + remark.handoffIds = [...remark.handoffIds, handoffId].slice(-HANDOFF_LIMIT); + remark.updatedAt = now; + } + this.changed(); + } + + applyReport(handoffId: string, numbers: readonly number[], note: string | null): number { + const handoff = this.handoffs.find((candidate) => candidate.id === handoffId); + if (!handoff) return 0; + const now = this.now(); + let applied = 0; + const reportNote = note ? note.slice(0, REMARK_TEXT_LIMIT) : null; + for (const remark of this.remarks) { + if (!handoff.remarkIds.includes(remark.id) || !numbers.includes(remark.number)) continue; + if (remark.status !== "sent" && remark.status !== "reported") continue; + if (remark.handoffIds.at(-1) !== handoffId) continue; + if (remark.status === "reported" && remark.report?.handoffId === handoffId && remark.report.note === reportNote) continue; + remark.status = "reported"; + remark.report = { handoffId, at: now, note: reportNote }; + remark.updatedAt = now; + applied += 1; + } + if (applied > 0) this.changed(); + return applied; + } + relink(id: string, candidate: unknown): Promise { return this.serial(() => this.relinkTo(id, candidate, false)); } @@ -434,36 +576,6 @@ export class MaterialService { }); } - private async relinkTo(id: string, candidate: unknown, sameFileOnly: boolean): Promise { - const material = this.materials.get(id); - if (!material || material.path === null) return failure("unavailable"); - if (typeof candidate !== "string" || !isAbsolute(candidate) || candidate.includes("\0")) return failure("unreadable"); - let resolved: string; - let info: BigIntStats; - try { - resolved = await realpath(candidate); - info = await stat(resolved, { bigint: true }); - } catch { - return failure("unreadable"); - } - if (!info.isFile()) return failure("not-a-file"); - if (sameFileOnly && (resolved !== candidate || !sameFile(info, material.identity))) return failure("unreadable"); - const other = this.findByPath(resolved); - if (other && other.id !== id) return failure("already-on-canvas"); - const name = displayName(candidate); - const type = materialType(basename(resolved)); - const kind = type.kind === "image" ? "image" : "file"; - if (kind !== material.kind) return failure("kind-mismatch"); - material.path = resolved; - material.name = name; - material.mimeType = kind === "image" ? type.mimeType : "application/octet-stream"; - material.identity = fileIdentity(info); - this.watchers.track(id, resolved); - await this.refreshLive(material, true); - this.changed(); - return { ok: true }; - } - async protocolResponse(request: Request): Promise { try { const url = new URL(request.url); @@ -474,9 +586,7 @@ export class MaterialService { if (parts[0] === "live" && parts.length === 1) { if (material.path === null) { const latest = material.versions.at(-1); - return latest - ? await streamFile(request, this.blobs.pathOf(latest.sha256), latest.mimeType, RESPONSE_HEADERS) - : textResponse("Material is unavailable.", 404); + return latest ? await this.streamVersion(request, latest) : textResponse("Material is unavailable.", 404); } const resolved = await realpath(material.path); const info = await stat(resolved); @@ -485,7 +595,7 @@ export class MaterialService { } if (parts[0] === "v" && parts.length === 2) { const version = material.versions.find((candidate) => candidate.id === parts[1]); - if (version) return await streamFile(request, this.blobs.pathOf(version.sha256), version.mimeType, RESPONSE_HEADERS); + return version ? await this.streamVersion(request, version) : textResponse("Version is unavailable.", 404); } return textResponse("Material is unavailable.", 404); } catch { @@ -514,52 +624,9 @@ export class MaterialService { if (this.refreshTimer !== null) clearTimeout(this.refreshTimer); await this.flush(); this.watchers.close(); - if (this.writable && !this.options.persist()) await this.collect(); - } - - private scheduleRefresh(ids: readonly string[]): void { - if (this.disposed) return; - for (const id of ids) this.pendingRefresh.add(id); - if (this.refreshTimer !== null) return; - this.refreshTimer = setTimeout(() => { - this.refreshTimer = null; - const pending = [...this.pendingRefresh]; - this.pendingRefresh.clear(); - void this.serial(async () => { - let any = false; - for (const id of pending) { - const material = this.materials.get(id); - if (material && await this.refreshLive(material)) any = true; - } - if (any) this.changed(false); - }); - }, REFRESH_DELAY_MS); - this.refreshTimer.unref?.(); - } - - private async refreshLive(material: StoredMaterial, forceRevision = false): Promise { - const previous = this.live.get(material.id); - const next = material.path === null - ? await this.blobs.has(material.versions.at(-1)?.sha256 ?? "") - ? captureLive(material) - : unavailable("unreadable", previous) - : await inspectWorkingFile(material, previous); - const changedContent = next.signature !== (previous?.signature ?? null); - const latest = material.versions.at(-1); - if (changedContent && material.path !== null && next.state === "ready" && latest && latest.signature !== next.signature - && latest.byteSize === next.byteSize && await fileDigest(material.path, latest.byteSize) === latest.sha256) { - latest.signature = next.signature; + if (this.writable && !this.options.persist() && await this.collect()) { + await rm(this.handoffsPath, { recursive: true, force: true }).catch(() => undefined); } - const revision = previous - ? previous.revision + (changedContent || forceRevision ? 1 : 0) - : Math.max(1, next.revision); - const updated: LiveState = { ...next, revision }; - if (next.state === "ready" && material.path !== null && next.identity) material.identity = next.identity; - this.live.set(material.id, updated); - return !previous - || previous.state !== updated.state - || previous.revision !== updated.revision - || previous.movedTo !== updated.movedTo; } private async createVersion(id: string, reason: MaterialVersionReason): Promise { @@ -582,7 +649,7 @@ export class MaterialService { } let blob; try { - blob = await this.blobs.writeFromFile(material.path, MATERIAL_VERSION_MAX_BYTES, await this.availableBytes()); + blob = await this.blobs.writeFromFile(material.path, MATERIAL_VERSION_MAX_BYTES, await this.available()); } catch (error) { return failure(blobFailure(error)); } @@ -607,7 +674,7 @@ export class MaterialService { createdAt: this.now(), reason, signature: live.signature, - natural: material.kind === "image" ? await readImageDimensions(this.blobs.pathOf(blob.sha256)) : null + natural: material.kind === "image" ? await readImageDimensions(this.blobs.pathOf(blob.sha256)).catch(() => null) : null }; material.nextVersion += 1; material.versions.push(version); @@ -627,9 +694,43 @@ export class MaterialService { kept.add(remark.target.versionId); if (remark.reference) kept.add(remark.reference.versionId); } + for (const handoff of this.handoffs) { + if (handoff.delivery.state !== "sending") continue; + for (const item of handoff.items) if (item.versionId) kept.add(item.versionId); + } return material.versions.find((version) => version.reason !== "pinned" && !kept.has(version.id)) ?? null; } + private async relinkTo(id: string, candidate: unknown, sameFileOnly: boolean): Promise { + const material = this.materials.get(id); + if (!material || material.path === null) return failure("unavailable"); + if (typeof candidate !== "string" || !isAbsolute(candidate) || candidate.includes("\0")) return failure("unreadable"); + let resolved: string; + let info: BigIntStats; + try { + resolved = await realpath(candidate); + info = await stat(resolved, { bigint: true }); + } catch { + return failure("unreadable"); + } + if (!info.isFile()) return failure("not-a-file"); + if (sameFileOnly && (resolved !== candidate || !sameFile(info, material.identity))) return failure("unreadable"); + const other = this.findByPath(resolved); + if (other && other.id !== id) return failure("already-on-canvas"); + const type = materialType(basename(resolved)); + const currentKind = material.kind === "file" ? materialType(basename(material.path)).kind : material.kind; + if (type.kind !== currentKind) return failure("kind-mismatch"); + material.path = resolved; + material.kind = type.kind; + material.name = displayName(candidate); + material.mimeType = type.mimeType; + material.identity = fileIdentity(info); + this.watchers.track(id, resolved); + await this.refreshLive(material, true); + this.changed(); + return { ok: true }; + } + private async versionable(id: string): Promise { const material = this.materials.get(id); if (!material) return false; @@ -638,6 +739,55 @@ export class MaterialService { return this.live.get(id)?.state === "ready"; } + private streamVersion(request: Request, version: StoredVersion): Promise { + return streamFile(request, this.blobs.pathOf(version.sha256), version.mimeType, RESPONSE_HEADERS); + } + + private scheduleRefresh(ids: readonly string[]): void { + if (this.disposed) return; + for (const id of ids) this.pendingRefresh.add(id); + if (this.refreshTimer !== null) return; + this.refreshTimer = setTimeout(() => { + this.refreshTimer = null; + const pending = [...this.pendingRefresh]; + this.pendingRefresh.clear(); + void this.serial(async () => { + let any = false; + for (const id of pending) { + const material = this.materials.get(id); + if (material && await this.refreshLive(material)) any = true; + } + if (any) this.changed(false); + }); + }, REFRESH_DELAY_MS); + this.refreshTimer.unref?.(); + } + + private async refreshLive(material: StoredMaterial, forceRevision = false): Promise { + const previous = this.live.get(material.id); + const next = material.path === null + ? await this.blobs.has(material.versions.at(-1)?.sha256 ?? "") + ? captureLive(material) + : unavailable("unreadable", previous) + : await inspectWorkingFile(material, previous); + const changedContent = next.signature !== (previous?.signature ?? null); + const latest = material.versions.at(-1); + if (changedContent && material.path !== null && next.state === "ready" && latest && latest.signature !== next.signature + && latest.byteSize === next.byteSize && await fileDigest(material.path, latest.byteSize) === latest.sha256) { + latest.signature = next.signature; + } + const revision = previous + ? previous.revision + (changedContent || forceRevision ? 1 : 0) + : Math.max(1, next.revision); + const updated: LiveState = { ...next, revision }; + if (next.state === "ready" && material.path !== null && next.identity) material.identity = next.identity; + this.live.set(material.id, updated); + return !previous + || previous.state !== updated.state + || previous.revision !== updated.revision + || previous.movedTo !== updated.movedTo; + } + private publicMaterial(material: StoredMaterial): CanvasMaterial { const live = this.live.get(material.id); return { @@ -684,6 +834,38 @@ export class MaterialService { return undefined; } + private async collect(): Promise { + try { + await this.writeState(true); + } catch { + return false; + } + await this.blobs.collect(this.disposed && !this.options.persist() ? new Set() : this.referencedHashes()); + return true; + } + + private referencedHashes(): Set { + const hashes = new Set(); + for (const material of this.materials.values()) { + for (const version of material.versions) hashes.add(version.sha256); + } + return hashes; + } + + private usedBytes(): number { + const sizes = new Map(); + for (const material of this.materials.values()) { + for (const version of material.versions) sizes.set(version.sha256, version.byteSize); + } + let total = 0; + for (const size of sizes.values()) total += size; + return total; + } + + private async available(): Promise { + return Math.max(0, this.storageLimit - await this.blobs.usedBytes()); + } + private changed(persist = true): void { this.revision += 1; if (persist) this.schedulePersist(); @@ -702,14 +884,20 @@ export class MaterialService { private writeState(strict = false): Promise { if (!this.writable) return strict ? Promise.reject(new Error("CanvasTTY materials state is not writable.")) : this.writeQueue; - const state = this.options.persist() - ? { version: MATERIAL_STATE_VERSION, materials: [...this.materials.values()], remarks: this.remarks, counters: this.counters } - : emptyMaterialState(); - const snapshot = JSON.stringify(state); const temporary = `${this.statePath}.tmp`; const write = this.writeQueue.catch(() => undefined).then(async () => { + const persist = this.options.persist(); + const snapshot = JSON.stringify(persist + ? { + version: MATERIAL_STATE_VERSION, + materials: [...this.materials.values()], + remarks: this.remarks, + handoffs: this.handoffs, + counters: this.counters + } + : emptyMaterialState()); await mkdir(this.root, { recursive: true, mode: 0o700 }); - await writeFile(temporary, snapshot, { encoding: "utf8", mode: 0o600 }); + await writeFile(temporary, snapshot, { encoding: "utf8", mode: 0o600, flush: true }); await rename(temporary, this.statePath); }); this.writeQueue = write.catch((error) => { @@ -728,42 +916,29 @@ export class MaterialService { private now(): number { return this.options.now?.() ?? Date.now(); } +} - private usedBytes(): number { - const sizes = new Map(); - for (const material of this.materials.values()) { - for (const version of material.versions) sizes.set(version.sha256, version.byteSize); - } - let total = 0; - for (const size of sizes.values()) total += size; - return total; - } - - private async availableBytes(): Promise { - return Math.max(0, this.storageLimit - await this.blobs.usedBytes()); - } - - private async collect(): Promise { - try { - await this.writeState(true); - } catch { - return; - } - await this.blobs.collect(this.disposed && !this.options.persist() ? new Set() : this.referencedHashes()); - } +type InspectedLive = LiveState & { identity?: StoredFileIdentity }; - private referencedHashes(): Set { - const hashes = new Set(); - for (const material of this.materials.values()) { - for (const version of material.versions) hashes.add(version.sha256); - } - return hashes; - } +function captureLive(material: StoredMaterial): InspectedLive { + const latest = material.versions.at(-1); + return { + state: latest ? "ready" : "unreadable", + signature: latest?.sha256 ?? null, + byteSize: latest?.byteSize ?? null, + modifiedAt: latest?.createdAt ?? null, + revision: latest?.number ?? 1, + movedTo: null + }; } -interface InspectedLive extends Omit { - revision: number; - identity?: StoredFileIdentity; +function finitePoint(point: unknown): Point { + if (!point || typeof point !== "object") return { x: 0, y: 0 }; + const { x, y } = point as Partial; + return { + x: typeof x === "number" && Number.isFinite(x) ? x : 0, + y: typeof y === "number" && Number.isFinite(y) ? y : 0 + }; } async function inspectWorkingFile(material: StoredMaterial, previous: LiveState | undefined): Promise { @@ -835,62 +1010,15 @@ async function findRenamed(directory: string, identity: StoredFileIdentity): Pro } async function readImageDimensions(path: string): Promise { - const flags = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); - let handle; + const handle = await open(path, READ_FILE_FLAGS); try { - handle = await open(path, flags); - if (!(await handle.stat()).isFile()) return null; - const buffer = Buffer.alloc(IMAGE_HEADER_BYTES); - const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + if (!(await handle.stat()).isFile()) throw new MaterialBlobError("unreadable", "The image cannot be read."); + const buffer = new Uint8Array(IMAGE_HEADER_BYTES); + const { bytesRead } = await handle.read(buffer, 0, IMAGE_HEADER_BYTES, 0); return imageDimensions(buffer.subarray(0, bytesRead)); - } catch { - return null; } finally { - await handle?.close().catch(() => undefined); - } -} - -function signatureOf(info: BigIntStats): string { - return `${info.size}:${info.mtimeNs}:${info.ino}`; -} - -function fileIdentity(info: BigIntStats): StoredFileIdentity { - return { dev: String(info.dev), ino: String(info.ino) }; -} - -function sameFile(info: BigIntStats, identity: StoredFileIdentity | null): boolean { - return identity !== null && String(info.dev) === identity.dev && String(info.ino) === identity.ino; -} - -function displayName(path: string): string { - return (basename(path) || "file").slice(0, MAX_NAME); -} - -function finitePoint(value: unknown): Point { - if (value && typeof value === "object") { - const { x, y } = value as Partial; - if (typeof x === "number" && typeof y === "number" && Number.isFinite(x) && Number.isFinite(y)) return { x, y }; + await handle.close().catch(() => undefined); } - return { x: 0, y: 0 }; -} - -function isBounds(value: unknown): value is SessionBounds { - if (!value || typeof value !== "object") return false; - const { position, size } = value as Partial; - return Boolean(position && size) - && [position!.x, position!.y, size!.width, size!.height].every((entry) => typeof entry === "number" && Number.isFinite(entry)); -} - -function captureLive(material: StoredMaterial): InspectedLive { - const latest = material.versions.at(-1); - return { - state: latest ? "ready" : "unreadable", - signature: latest?.sha256 ?? null, - byteSize: latest?.byteSize ?? null, - modifiedAt: latest?.createdAt ?? null, - revision: latest?.number ?? 1, - movedTo: null - }; } function parseRemarkDraft(value: unknown): RemarkDraft | null { @@ -934,21 +1062,39 @@ function remarkTransitionAllowed(from: MaterialRemark["status"], to: NonNullable function anchorFits(material: StoredMaterial, anchor: RemarkAnchor): boolean { switch (anchor.kind) { case "whole": return true; - case "lines": return material.kind === "text"; - case "time": return material.kind === "video" || material.kind === "audio"; - case "page": return material.kind === "pdf"; - case "step": return false; case "region": case "point": return material.kind === "image"; + default: return false; } } +function signatureOf(info: BigIntStats): string { + return `${info.size}:${info.mtimeNs}:${info.ino}`; +} + +function fileIdentity(info: BigIntStats): StoredFileIdentity { + return { dev: String(info.dev), ino: String(info.ino) }; +} + +function sameFile(info: BigIntStats, identity: StoredFileIdentity | null): boolean { + return identity !== null && String(info.dev) === identity.dev && String(info.ino) === identity.ino; +} + +function displayName(path: string): string { + return (basename(path) || "file").slice(0, MAX_NAME); +} + + + +function isBounds(value: unknown): value is SessionBounds { + if (!value || typeof value !== "object") return false; + const { position, size } = value as Partial; + return Boolean(position && size) + && [position!.x, position!.y, size!.width, size!.height].every((entry) => typeof entry === "number" && Number.isFinite(entry)); +} + function blobFailure(error: unknown): MaterialFailure { - if (error instanceof MaterialBlobError) { - if (error.code === "too-large") return "too-large"; - if (error.code === "quota") return "quota"; - } - return "unreadable"; + return error instanceof MaterialBlobError ? error.code : "unreadable"; } function failure(reason: MaterialFailure): { ok: false; reason: MaterialFailure } { diff --git a/src/main/services/materials/electronImageOps.ts b/src/main/services/materials/electronImageOps.ts new file mode 100644 index 00000000..57e695d8 --- /dev/null +++ b/src/main/services/materials/electronImageOps.ts @@ -0,0 +1,50 @@ +import { readFile } from "node:fs/promises"; +import { nativeImage } from "electron"; +import type { Size } from "../../../shared/contracts"; +import type { HandoffImageOps } from "./HandoffService.ts"; +import { clampRect, drawOutline, outlineThickness, type PixelRect } from "./imageRegions.ts"; + +const OUTLINE = [0xf0, 0x2a, 0xf0, 0xff] as const; +const MAX_PIXELS = 40_000_000; +const DRAWABLE_TYPES: ReadonlySet = new Set(["image/png", "image/jpeg"]); + +export const electronImageOps: HandoffImageOps = { + canDraw(mimeType, natural) { + return DRAWABLE_TYPES.has(mimeType) && natural.width > 0 && natural.height > 0 && natural.width * natural.height <= MAX_PIXELS; + }, + async marked(source, rect, natural) { + const image = await decode(source, natural); + if (!image) return null; + const size = image.getSize(); + const bitmap = Buffer.from(image.toBitmap()); + drawOutline(bitmap, size, scaleRect(rect, natural, size), outlineThickness(size), OUTLINE); + return nativeImage.createFromBitmap(bitmap, { width: size.width, height: size.height }).toPNG(); + }, + async crop(source, rect, natural) { + const image = await decode(source, natural); + if (!image) return null; + return image.crop(scaleRect(rect, natural, image.getSize())).toPNG(); + } +}; + +async function decode(source: string, natural: Size): Promise { + if (!(natural.width > 0) || !(natural.height > 0) || natural.width * natural.height > MAX_PIXELS) return null; + const image = nativeImage.createFromBuffer(await readFile(source)); + if (image.isEmpty()) return null; + const size = image.getSize(); + if (size.width * size.height > MAX_PIXELS) return null; + const rotated = size.width !== size.height && size.width === natural.height && size.height === natural.width; + return rotated ? null : image; +} + +function scaleRect(rect: PixelRect, natural: Size, size: Size): PixelRect { + if (natural.width === size.width && natural.height === size.height) return rect; + const scaleX = size.width / natural.width; + const scaleY = size.height / natural.height; + return clampRect({ + x: Math.round(rect.x * scaleX), + y: Math.round(rect.y * scaleY), + width: Math.max(1, Math.round(rect.width * scaleX)), + height: Math.max(1, Math.round(rect.height * scaleY)) + }, size); +} diff --git a/src/main/services/materials/handoffText.ts b/src/main/services/materials/handoffText.ts new file mode 100644 index 00000000..eef7dfa9 --- /dev/null +++ b/src/main/services/materials/handoffText.ts @@ -0,0 +1,206 @@ +import type { LocaleId, RemarkAnchor, Size } from "../../../shared/contracts"; +import { anchorRect } from "./imageRegions.ts"; +import { formatClock } from "../../../shared/materials.ts"; + +export const HANDOFF_TEXT_LIMIT = 16_000; + +export type HandoffImageMode = "claude" | "codex" | "paths"; + +export interface HandoffTextTarget { + name: string; + versionNumber: number; + anchor: RemarkAnchor; + natural: Size | null; + file: string; + marked: string | null; + crop: string | null; + location: string | null; +} + +export interface HandoffTextRemark { + number: number; + text: string; + target: HandoffTextTarget; + reference: HandoffTextTarget | null; +} + +export interface HandoffTextImage { + name: string; + path: string; +} + +export interface HandoffTextInput { + locale: LocaleId; + number: number; + folder: string; + remarks: HandoffTextRemark[]; + editable: string[]; + note: string; + resultsFolder: string | null; + reportFile: string | null; + imageMode: HandoffImageMode; + images: HandoffTextImage[]; +} + +const STRINGS = { + ru: { + header: (number: number, count: number) => `CanvasTTY · передача #${number} · замечаний: ${count}`, + folder: "Файлы передачи (снимки версий и выделения) лежат в папке:", + editable: "Эти рабочие файлы можно менять:", + onlyEditable: "Остальные файлы — только для справки, их не меняй.", + noEditable: "Исходные файлы не меняй — новые варианты сохраняй отдельными файлами.", + version: "версия", + source: "Исходный файл:", + snapshot: "Снимок версии:", + marked: "Кадр с выделением:", + crop: "Фрагмент крупно:", + reference: "Референс:", + requirement: "Требование:", + note: "Дополнительно:", + whole: "всё целиком", + region: (rect: string, size: string) => `область ${rect} из ${size} px`, + point: (x: number, y: number, size: string) => `точка (${x}, ${y}) из ${size} px`, + regionShare: (x: string, y: string) => `область: по ширине ${x}, по высоте ${y}`, + pointShare: (x: string, y: string) => `точка: по ширине ${x}, по высоте ${y}`, + line: (line: number) => `строка ${line}`, + lines: (start: number, end: number) => `строки ${start}–${end}`, + moment: (time: string) => `момент ${time}`, + pdfPage: (page: number) => `страница ${page}`, + span: (start: string, end: string) => `отрезок ${start}–${end}`, + finish: (numbers: string) => `Когда закончишь, ответь, какие замечания (${numbers}) считаешь исправленными и что изменил.`, + results: "Новые файлы результата сохраняй в папку", + resultsTail: "для проверки.", + report: "Отчёт запиши в", + reportShape: "в виде {\"fixed\":[номера],\"note\":\"что сделано\"}.", + attached: "Приложенные изображения по порядку:", + open: "Изображения (открой их):", + pointer: (number: number) => `CanvasTTY · передача #${number}: полный текст замечаний в файле`, + pointerTail: "— прочитай его целиком." + }, + en: { + header: (number: number, count: number) => `CanvasTTY · handoff #${number} · remarks: ${count}`, + folder: "Handoff files (version snapshots and highlights) are in:", + editable: "You may change these working files:", + onlyEditable: "The other files are for reference only — do not change them.", + noEditable: "Do not change the source files — save new variants as separate files.", + version: "version", + source: "Source file:", + snapshot: "Version snapshot:", + marked: "Frame with the highlight:", + crop: "Close-up:", + reference: "Reference:", + requirement: "Requirement:", + note: "Also:", + whole: "the whole file", + region: (rect: string, size: string) => `area ${rect} of ${size} px`, + point: (x: number, y: number, size: string) => `point (${x}, ${y}) of ${size} px`, + regionShare: (x: string, y: string) => `area ${x} across, ${y} down`, + pointShare: (x: string, y: string) => `point ${x} across, ${y} down`, + line: (line: number) => `line ${line}`, + lines: (start: number, end: number) => `lines ${start}–${end}`, + moment: (time: string) => `at ${time}`, + pdfPage: (page: number) => `page ${page}`, + span: (start: string, end: string) => `${start}–${end}`, + finish: (numbers: string) => `When you are done, reply which remarks (${numbers}) you consider fixed and what you changed.`, + results: "Save new result files into", + resultsTail: "for review.", + report: "Write the report to", + reportShape: "as {\"fixed\":[numbers],\"note\":\"what was done\"}.", + attached: "Attached images, in order:", + open: "Images (open them):", + pointer: (number: number) => `CanvasTTY · handoff #${number}: the full remarks are in`, + pointerTail: "— read it completely." + } +} as const; + +export function describeAnchor(anchor: RemarkAnchor, natural: Size | null, locale: LocaleId): string { + const strings = STRINGS[locale]; + if (anchor.kind === "lines") return anchor.start === anchor.end ? strings.line(anchor.start) : strings.lines(anchor.start, anchor.end); + if (anchor.kind === "page") return strings.pdfPage(anchor.page); + if (anchor.kind === "time") return anchor.end === null ? strings.moment(formatClock(anchor.start)) : strings.span(formatClock(anchor.start), formatClock(anchor.end)); + if (anchor.kind === "whole") return strings.whole; + if (!natural) { + return anchor.kind === "point" + ? strings.pointShare(share(anchor.x), share(anchor.y)) + : strings.regionShare(`${share(anchor.x)}–${share(anchor.x + anchor.width)}`, `${share(anchor.y)}–${share(anchor.y + anchor.height)}`); + } + const size = `${natural.width}×${natural.height}`; + if (anchor.kind === "point") { + return strings.point(Math.round(anchor.x * natural.width), Math.round(anchor.y * natural.height), size); + } + const rect = anchorRect(anchor, natural); + return strings.region(`x ${rect.x}–${rect.x + rect.width}, y ${rect.y}–${rect.y + rect.height}`, size); +} + +export function terminalSafe(text: string): string { + return text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, ""); +} + +export function inline(text: string): string { + return text.replace(/[\r\n\u0085\u2028\u2029]+/g, " ").replace(/[\u202a-\u202e\u2066-\u2069]/g, ""); +} + +export function handoffText(input: HandoffTextInput): string { + const strings = STRINGS[input.locale]; + const lines: string[] = [strings.header(input.number, input.remarks.length), "", strings.folder, code(input.folder)]; + if (input.editable.length > 0) { + lines.push("", strings.editable, ...input.editable.map((path) => `- ${code(path)}`), strings.onlyEditable); + } else { + lines.push("", strings.noEditable); + } + for (const remark of input.remarks) { + const target = remark.target; + lines.push("", `#${remark.number} · ${inline(target.name)}, ${strings.version} ${target.versionNumber} · ${describeAnchor(target.anchor, target.natural, input.locale)}`); + if (target.location) lines.push(`${strings.source} ${code(target.location)}`); + lines.push(`${strings.snapshot} ${code(target.file)}`); + if (target.marked) lines.push(`${strings.marked} ${code(target.marked)}`); + if (target.crop) lines.push(`${strings.crop} ${code(target.crop)}`); + if (remark.reference) { + const reference = remark.reference; + const details = [ + `${inline(reference.name)}, ${strings.version} ${reference.versionNumber}`, + describeAnchor(reference.anchor, reference.natural, input.locale), + reference.crop ? `${strings.crop} ${code(reference.crop)}` : `${strings.snapshot} ${code(reference.file)}` + ]; + lines.push(`${strings.reference} ${details.join(" · ")}`); + } + lines.push(`${strings.requirement} ${remark.text}`); + } + if (input.note.trim()) lines.push("", `${strings.note} ${input.note.trim()}`); + lines.push("", strings.finish(input.remarks.map((remark) => `#${remark.number}`).join(", "))); + if (input.resultsFolder) { + lines.push(`${strings.results} ${code(input.resultsFolder)} ${strings.resultsTail}`); + if (input.reportFile) lines.push(`${strings.report} ${code(input.reportFile)} ${strings.reportShape}`); + } + return terminalSafe(lines.join("\n") + imageBlock(input)); +} + +export function handoffPointerText(input: HandoffTextInput, handoffFile: string): string { + const strings = STRINGS[input.locale]; + return terminalSafe(`${strings.pointer(input.number)} ${code(handoffFile)} ${strings.pointerTail}` + imageBlock(input)); +} + +function imageBlock(input: HandoffTextInput): string { + if (input.images.length === 0) return ""; + const strings = STRINGS[input.locale]; + const names = `${strings.attached} ${input.images.map((image, index) => `${index + 1}) ${code(image.name)}`).join(" ")}`; + if (input.imageMode === "claude") return `\n\n${names}\n${input.images.map((image) => image.path).join("\n")}`; + if (input.imageMode === "codex") return `\n\n${names}`; + return `\n\n${strings.open}\n${input.images.map((image) => `- ${code(image.path)}`).join("\n")}`; +} + +function quoted(value: string, locale: LocaleId): string { + return locale === "ru" ? `«${inline(value)}»` : `“${inline(value)}”`; +} + +function share(value: number): string { + return `${Math.round(value * 100)}%`; +} + +function code(value: string): string { + const text = inline(value); + const longest = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length)); + const fence = "`".repeat(longest + 1); + const padding = text.startsWith("`") || text.endsWith("`") ? " " : ""; + return `${fence}${padding}${text}${padding}${fence}`; +} diff --git a/src/main/services/materials/materialState.ts b/src/main/services/materials/materialState.ts index f5242f8b..21921e68 100644 --- a/src/main/services/materials/materialState.ts +++ b/src/main/services/materials/materialState.ts @@ -1,17 +1,22 @@ import { isAbsolute } from "node:path"; import type { + HandoffDeliveryState, + HandoffPasteNote, + MaterialHandoff, MaterialKind, MaterialOrigin, MaterialRemark, MaterialVersionReason, Point, + ProviderId, RemarkAnchor, RemarkStatus, RemarkTarget, Size -} from "../../../shared/contracts.ts"; +} from "../../../shared/contracts"; import { clampSize, + HANDOFF_NOTE_LIMIT, MATERIAL_LIMIT, MATERIAL_VERSION_LIMIT, REMARK_TEXT_LIMIT @@ -19,12 +24,20 @@ import { export const MATERIAL_STATE_VERSION = 1; export const REMARK_LIMIT = 2_000; +export const HANDOFF_LIMIT = 200; +export const HANDOFF_REMARK_LIMIT = 50; -const KINDS: ReadonlySet = new Set(["image", "text", "video", "audio", "pdf", "file"]); +const KINDS: Record = { image: true, text: true, video: true, audio: true, pdf: true, file: true }; const VERSION_REASONS: ReadonlySet = new Set(["pinned", "remark", "capture", "edit"]); const REMARK_STATUSES: ReadonlySet = new Set(["open", "sent", "reported", "accepted", "reopened"]); +const DELIVERY_STATES: ReadonlySet = new Set(["sending", "submitted", "pasted", "failed"]); +const PASTE_NOTES: ReadonlySet = new Set(["not-seen", "not-observed", "enter-failed"]); +const PROVIDER_PATTERN = /^[a-z][a-z0-9-]{0,31}$/; const ID_PATTERN = /^[a-f0-9-]{36}$/; export const SHA256_PATTERN = /^[a-f0-9]{64}$/; +export const MAX_ELEMENT_ROLE = 60; +export const MAX_ELEMENT_NAME = 200; +export const MAX_SESSION_ID = 120; const MAX_NAME = 255; const MAX_URL = 2_048; const MAX_TITLE = 300; @@ -70,11 +83,12 @@ export interface StoredMaterialState { version: typeof MATERIAL_STATE_VERSION; materials: StoredMaterial[]; remarks: MaterialRemark[]; - counters: { remark: number }; + handoffs: MaterialHandoff[]; + counters: { remark: number; handoff: number }; } export function emptyMaterialState(): StoredMaterialState { - return { version: MATERIAL_STATE_VERSION, materials: [], remarks: [], counters: { remark: 0 } }; + return { version: MATERIAL_STATE_VERSION, materials: [], remarks: [], handoffs: [], counters: { remark: 0, handoff: 0 } }; } export function restoreMaterialState(candidate: unknown): StoredMaterialState { @@ -144,9 +158,19 @@ export function normalizeMaterialState(candidate: unknown): StoredMaterialState state.remarks.push(remark); } } + if (Array.isArray(candidate.handoffs)) { + const handoffIds = new Set(); + for (const value of candidate.handoffs.slice(-HANDOFF_LIMIT)) { + const handoff = normalizeHandoff(value); + if (!handoff || handoffIds.has(handoff.id)) continue; + handoffIds.add(handoff.id); + state.handoffs.push(handoff); + } + } const counters = isRecord(candidate.counters) ? candidate.counters : {}; state.counters = { - remark: Math.max(counterValue(counters.remark), ...state.remarks.map((remark) => remark.number)) + remark: Math.max(counterValue(counters.remark), ...state.remarks.map((remark) => remark.number)), + handoff: Math.max(counterValue(counters.handoff), ...state.handoffs.map((handoff) => handoff.number)) }; return state; } @@ -184,6 +208,14 @@ function normalizeRemark(value: unknown): MaterialRemark | null { if (typeof value.text !== "string" || value.text.length > REMARK_TEXT_LIMIT) return null; if (typeof value.status !== "string" || !REMARK_STATUSES.has(value.status as RemarkStatus)) return null; if (!isFiniteNumber(value.createdAt) || !isFiniteNumber(value.updatedAt)) return null; + const handoffIds = Array.isArray(value.handoffIds) ? value.handoffIds.filter(isId).slice(-HANDOFF_LIMIT) : []; + const report = isRecord(value.report) && isId(value.report.handoffId) && isFiniteNumber(value.report.at) + ? { + handoffId: value.report.handoffId, + at: value.report.at, + note: typeof value.report.note === "string" ? value.report.note.slice(0, REMARK_TEXT_LIMIT) : null + } + : null; return { id: value.id, number: value.number, @@ -193,8 +225,53 @@ function normalizeRemark(value: unknown): MaterialRemark | null { status: value.status as RemarkStatus, createdAt: value.createdAt, updatedAt: value.updatedAt, - handoffIds: [], - report: null + handoffIds, + report + }; +} + +function normalizeHandoff(value: unknown): MaterialHandoff | null { + if (!isRecord(value) || !isId(value.id) || !isCount(value.number)) return null; + if (!isFiniteNumber(value.createdAt) || typeof value.sessionId !== "string" || value.sessionId.length > MAX_SESSION_ID) return null; + if (typeof value.sessionTitle !== "string" || typeof value.provider !== "string" || !PROVIDER_PATTERN.test(value.provider)) return null; + if (!Array.isArray(value.remarkIds) || typeof value.note !== "string" || typeof value.folder !== "string" || !isAbsolute(value.folder)) return null; + if (value.resultsFolder !== null && (typeof value.resultsFolder !== "string" || !isAbsolute(value.resultsFolder))) return null; + const delivery = isRecord(value.delivery) ? value.delivery : null; + if (!delivery || typeof delivery.state !== "string" || !DELIVERY_STATES.has(delivery.state as HandoffDeliveryState)) return null; + const items = Array.isArray(value.items) ? value.items.flatMap((item) => ( + isRecord(item) && isId(item.materialId) && (item.versionId === null || isId(item.versionId)) && typeof item.editable === "boolean" + ? [{ materialId: item.materialId, versionId: item.versionId as string | null, editable: item.editable }] + : [] + )).slice(0, HANDOFF_REMARK_LIMIT * 2) : []; + const state = delivery.state === "sending" ? "failed" : delivery.state as HandoffDeliveryState; + return { + id: value.id, + number: value.number, + createdAt: value.createdAt, + sessionId: value.sessionId, + sessionTitle: value.sessionTitle.slice(0, MAX_TITLE), + provider: value.provider as ProviderId, + remarkIds: value.remarkIds.filter(isId).slice(0, HANDOFF_REMARK_LIMIT), + items, + note: value.note.slice(0, HANDOFF_NOTE_LIMIT), + folder: value.folder, + resultsFolder: value.resultsFolder as string | null, + sessionStartedAt: isFiniteNumber(value.sessionStartedAt) ? value.sessionStartedAt : null, + delivery: { + state, + imagesExpected: counterValue(delivery.imagesExpected), + imagesAttached: counterValue(delivery.imagesAttached), + sentAt: isFiniteNumber(delivery.sentAt) ? delivery.sentAt : null, + turnStartedAt: isFiniteNumber(delivery.turnStartedAt) ? delivery.turnStartedAt : null, + turnEndedAt: isFiniteNumber(delivery.turnEndedAt) ? delivery.turnEndedAt : null, + note: state === "pasted" && typeof delivery.note === "string" && PASTE_NOTES.has(delivery.note as HandoffPasteNote) + ? delivery.note as HandoffPasteNote + : null, + error: delivery.state === "sending" + ? "CanvasTTY closed while sending." + : typeof delivery.error === "string" ? delivery.error.slice(0, 500) : null, + stateSaved: delivery.stateSaved !== false + } }; } @@ -218,7 +295,7 @@ function normalizeMaterial(value: unknown): StoredMaterial | null { if (!isRecord(value)) return null; const { id, kind, name, mimeType, position, size, path, identity, origin, createdAt, versions, nextVersion } = value; if (!isId(id)) return null; - if (typeof kind !== "string" || !KINDS.has(kind as MaterialKind)) return null; + if (typeof kind !== "string" || !Object.hasOwn(KINDS, kind)) return null; if (typeof name !== "string" || name.length === 0 || name.length > MAX_NAME) return null; if (typeof mimeType !== "string" || !MIME_PATTERN.test(mimeType)) return null; if (!isPoint(position) || !isSize(size)) return null; diff --git a/src/main/services/materials/sessionScreen.ts b/src/main/services/materials/sessionScreen.ts new file mode 100644 index 00000000..d4e96759 --- /dev/null +++ b/src/main/services/materials/sessionScreen.ts @@ -0,0 +1,114 @@ +import type { TerminalDataEvent } from "../../../shared/contracts"; +import { lazyRequire } from "../../lazyRequire.ts"; + +const xterm = lazyRequire("@xterm/headless"); + +export interface ScreenPort { + geometry(id: string): { cols: number; rows: number }; + readBuffer(id: string): { buffer: string; outputOffset: number }; +} + +export class SessionScreen { + readonly id: string; + private readonly port: ScreenPort; + private readonly terminal: import("@xterm/headless").Terminal; + private ready: Promise; + private offset: number; + private disposed = false; + + constructor(port: ScreenPort, id: string) { + this.id = id; + this.port = port; + const geometry = port.geometry(id); + this.terminal = new (xterm().Terminal)({ cols: geometry.cols, rows: geometry.rows, allowProposedApi: true, scrollback: 200 }); + const snapshot = port.readBuffer(id); + this.offset = snapshot.outputOffset; + this.ready = new Promise((resolve) => this.terminal.write(snapshot.buffer, resolve)); + } + + feed(event: TerminalDataEvent): void { + if (event.id !== this.id) return; + const overlap = Math.max(0, this.offset - (event.outputOffset - event.data.length)); + const data = event.data.slice(overlap); + this.offset = Math.max(this.offset, event.outputOffset); + if (!data || this.disposed) return; + let geometry: { cols: number; rows: number } | null = null; + try { + geometry = this.port.geometry(this.id); + } catch { + geometry = null; + } + this.ready = this.ready.then(() => new Promise((resolve) => { + if (this.disposed) return resolve(); + if (geometry && (geometry.cols !== this.terminal.cols || geometry.rows !== this.terminal.rows)) this.terminal.resize(geometry.cols, geometry.rows); + this.terminal.write(data, resolve); + })); + } + + async text(): Promise { + await this.ready; + if (this.disposed) return ""; + const buffer = this.terminal.buffer.active; + const lines: string[] = []; + for (let row = buffer.baseY; row < buffer.baseY + this.terminal.rows; row += 1) { + lines.push(buffer.getLine(row)?.translateToString(true) ?? ""); + } + return lines.join("\n"); + } + + async typedAfter(marker: string): Promise { + await this.ready; + if (this.disposed) return null; + const buffer = this.terminal.buffer.active; + let boundary = buffer.baseY + this.terminal.rows; + for (let row = boundary - 1; row >= buffer.baseY; row -= 1) { + const line = buffer.getLine(row); + const text = line?.translateToString(true) ?? ""; + if (/[─│╭╰═┃]{3,}/u.test(text)) { + boundary = row; + break; + } + } + const collect = (line: { length: number; getCell(column: number): { getChars(): string; isDim(): number } | undefined } | undefined, from: number): string => { + if (!line) return ""; + let typed = ""; + for (let column = from; column < line.length; column += 1) { + const cell = line.getCell(column); + const chars = cell?.getChars() ?? ""; + if (cell && chars.trim() && !cell.isDim()) typed += chars; + } + return typed; + }; + let sawMarker = false; + for (let row = boundary - 1; row >= buffer.baseY; row -= 1) { + const line = buffer.getLine(row); + const text = line?.translateToString(true) ?? ""; + if (/[─│╭╰═┃]{3,}/u.test(text)) break; + const at = text.indexOf(marker); + if (!line || at === -1 || text.slice(0, at).trim() !== "") continue; + sawMarker = true; + let typed = collect(line, at + marker.length); + for (let below = row + 1; below < boundary; below += 1) typed += collect(buffer.getLine(below), 0); + if (typed !== "") return typed; + } + return sawMarker ? "" : null; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.terminal.dispose(); + } +} + +export function withoutBraille(screen: string): string { + return screen.replace(/[\u2800-\u28ff]/g, " "); +} + +export function imageMarkers(screen: string): number { + return new Set(screen.match(/\[Image #\d+\]/g) ?? []).size; +} + +export function pasteMarkers(screen: string): number { + return (screen.match(/\[Pasted (?:text #\d+[^\]]*|Content[^\]]*)\]/g) ?? []).length; +} diff --git a/src/main/services/terminalLaunch.ts b/src/main/services/terminalLaunch.ts index 20110f88..c97d193a 100644 --- a/src/main/services/terminalLaunch.ts +++ b/src/main/services/terminalLaunch.ts @@ -42,6 +42,8 @@ interface LaunchResolutionOptions { isolated?: boolean; /** The folder the CLI runs in (OpenCode's project configuration is read from it). */ cwd?: string; + initialPrompt?: string; + initialImagePath?: string; } const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -100,7 +102,9 @@ export function resolveTerminalLaunch( : agentBrowserArgs), ...providerModelArguments(provider, options.model), ...providerEffortArguments(provider, options.effort), - ...(options.resumePrevious ? resolveResumeArguments(provider, options.resumeThreadId) : []) + ...(options.resumePrevious ? resolveResumeArguments(provider, options.resumeThreadId) : []), + ...(provider === "codex" && options.initialPrompt && options.initialImagePath + ? [options.initialPrompt.replace(/[\r\n]+/g, " "), "--image", options.initialImagePath] : []) ]; const combinedEnvironment = { ...providerCli.environment, diff --git a/src/main/services/usageReaders.ts b/src/main/services/usageReaders.ts new file mode 100644 index 00000000..4b843e7e --- /dev/null +++ b/src/main/services/usageReaders.ts @@ -0,0 +1,171 @@ +import { createHash } from 'node:crypto'; +import type { UsageEvent } from '../../shared/usageHistory.ts'; + +/* + * Pure parsers for agent session logs. Records are parsed in memory, but only accounting + * fields are ever copied out: no message, prompt or tool content leaves these functions. + * + * Token semantics shared by every source: `input` is the whole prompt (uncached + cache + * reads + cache writes), `cached` is the cache-read subset of `input`, and `output` already + * includes reasoning tokens, which are therefore never added a second time. + */ + +export function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; +} +/** Optional counter: absent/null is 0; anything but a safe non-negative integer is invalid. */ +export function tokens(value: unknown): number | null { + return value === undefined || value === null ? 0 : required(value); +} +function required(value: unknown): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null; +} +export function fingerprint(value: string | Uint8Array): string { return createHash('sha256').update(value).digest('hex').slice(0, 24); } +export function identifier(value: unknown): string { + return typeof value === 'string' && /^[A-Za-z0-9_.:-]{1,180}$/.test(value) ? value : ''; +} +function timestamp(value: unknown): number { + return typeof value === 'string' ? Date.parse(value) : Number.NaN; +} + +export interface LogContext { + profile: string; + /** Used when a log does not name its session (e.g. the id is derived from the file name). */ + session: string; +} + +/** [input including cached, cached input, output including reasoning] */ +export type CodexTokens = [number, number, number]; +/** Per-rollout reader state. JSON-serializable: it is persisted in collector cursors. */ +export interface CodexFileState { + /** Session of the first session_meta line; later session_meta lines are copied history. */ + session?: string; + app?: string; + /** Rollout written for another accounted app (Hermes' codex app-server runtime). */ + external?: string; + /** Last valid cumulative total_token_usage of this file. */ + total?: CodexTokens; + /** Copied parent history is being replayed; its counters only rebaseline until this time. */ + copyUntil?: number; +} +/** A forked rollout replays the parent's lines in one burst right after the second session_meta. */ +export const CODEX_COPY_WINDOW = 5_000; + +function codexTokens(value: unknown): { tokens: CodexTokens; reasoning: number } | null { + const u = record(value); + const input = required(u.input_tokens), output = required(u.output_tokens); + const cached = tokens(u.cached_input_tokens), reasoning = tokens(u.reasoning_output_tokens); + if (input === null || output === null || cached === null || reasoning === null || cached > input) return null; + return { tokens: [input, cached, output], reasoning }; +} +function codexApp(originator: string): string { + if (/vscode|ide|jetbrains/i.test(originator)) return 'Codex IDE'; + if (/desktop/i.test(originator)) return 'Codex Desktop'; + if (/exec/i.test(originator)) return 'Codex Exec'; + return /cli/i.test(originator) ? 'Codex CLI' : 'Codex'; +} +const covers = (a: CodexTokens, b: CodexTokens): boolean => a.every((value, index) => value >= b[index]); + +/** + * Codex token_count carries cumulative totals, which are differenced, never summed. A reading + * without a comparable previous total (resumed, forked or compacted lineage, or an interrupted + * cursor) is not incremental: only its last_token_usage is new. A decrease rebaselines. + * Event ids combine the file's lineage (its first session_meta id) with the counters, so copies + * of one rollout deduplicate by id even when they rewrote timestamps, while independent sessions + * with equal counters stay distinct. Parent history replayed into a fork is suppressed by the + * copy window instead. + */ +export function codexUsage(raw: unknown, state: CodexFileState, context: LogContext): UsageEvent | null { + const r = record(raw), p = record(r.payload), at = timestamp(r.timestamp); + if (r.type === 'session_meta') { + const id = identifier(p.id); + if (!state.session) { + const originator = typeof p.originator === 'string' ? p.originator : ''; + state.session = id || context.session; + state.app = codexApp(originator); + if (/hermes/i.test(originator)) state.external = 'Hermes'; + } else if (id && id !== state.session && Number.isFinite(at)) { + state.copyUntil = at + CODEX_COPY_WINDOW; + } + return null; + } + if (r.type !== 'event_msg' || p.type !== 'token_count') return null; + const info = record(p.info), total = codexTokens(info.total_token_usage); + if (!total) return null; + const last = codexTokens(info.last_token_usage); + const previous = state.total; + state.total = total.tokens; + const copied = state.copyUntil !== undefined && !(Number.isFinite(at) && at > state.copyUntil); + if (!copied) delete state.copyUntil; + if (copied || state.external || !Number.isFinite(at)) return null; + const fresh = last && last.tokens.every((value, index) => value === total.tokens[index]); + const delta = previous && covers(total.tokens, previous) ? total.tokens.map((value, index) => value - previous[index]) + : fresh ? total.tokens + : !previous && last && covers(total.tokens, last.tokens) ? last.tokens + : null; + if (!delta || (!delta[0] && !delta[2])) return null; + const session = state.session || context.session; + const counters = [total.tokens, total.reasoning, last?.tokens ?? null, last?.reasoning ?? null]; + return { + id: fingerprint(JSON.stringify(['codex', session, ...counters])), + provider: 'codex', app: state.app ?? 'Codex', profile: context.profile, session, + from: at, to: at, input: delta[0], output: delta[2], cached: Math.min(delta[1], delta[0]), timing: 'event', + }; +} + +interface ClaudeMessage { + id: string; + session: string; + from: number; + to: number; + /** [uncached input, cache read, cache write, output] */ + usage: [number, number, number, number]; +} +/** Per-transcript reader state. JSON-serializable: it is persisted in collector cursors. */ +export interface ClaudeFileState { + /** Newest assistant message; streamed entries of one message repeat its id with growing usage. */ + pending?: ClaudeMessage; +} + +function claudeMessage(raw: unknown, context: LogContext): ClaudeMessage | null { + const r = record(raw), m = record(r.message), u = record(m.usage), at = timestamp(r.timestamp); + const id = identifier(m.id); + if (r.type !== 'assistant' || !id || !Number.isFinite(at) || !Object.keys(u).length) return null; + if (m.model === '' || r.isApiErrorMessage === true) return null; + const usage = [required(u.input_tokens), tokens(u.cache_read_input_tokens), tokens(u.cache_creation_input_tokens), required(u.output_tokens)]; + if (usage.some(value => value === null)) return null; + return { id, session: identifier(r.sessionId) || context.session, from: at, to: at, usage: usage as ClaudeMessage['usage'] }; +} +function claudeEvent(message: ClaudeMessage, context: LogContext): UsageEvent | null { + const [input, read, write, output] = message.usage; + if (!input && !read && !write && !output) return null; + return { + id: fingerprint(`claude:${message.id}`), provider: 'claude', app: 'Claude Code', profile: context.profile, + session: message.session, from: message.from, to: message.to, input: input + read + write, output, cached: read, timing: 'event', + }; +} + +/** + * Claude Code writes one entry per streamed content block, all sharing the API message id, with + * usage that can grow until the last one; copied/resumed transcripts repeat the same ids. The + * message is emitted once, with per-counter maxima, when a different message starts or on flush. + */ +export function claudeUsage(raw: unknown, state: ClaudeFileState, context: LogContext): UsageEvent | null { + const message = claudeMessage(raw, context); + if (!message) return null; + const pending = state.pending; + if (pending?.id === message.id) { + pending.from = Math.min(pending.from, message.from); + pending.to = Math.max(pending.to, message.to); + pending.usage = pending.usage.map((value, index) => Math.max(value, message.usage[index])) as ClaudeMessage['usage']; + return null; + } + state.pending = message; + return pending ? claudeEvent(pending, context) : null; +} +/** Emits the pending message once its transcript went idle. */ +export function claudeFlush(state: ClaudeFileState, context: LogContext): UsageEvent | null { + const pending = state.pending; + delete state.pending; + return pending ? claudeEvent(pending, context) : null; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 78821131..32f38cf6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -14,7 +14,10 @@ import type { CanvasTTYApi, CustomTerminalBorderSkinId, CreateSessionRequest, + FileRootReference, + HandoffDraft, MaterialsSnapshot, + PluginBrowserOpenRequest, PluginBrowserOpenResponse, PluginCanvasRequest, @@ -29,6 +32,8 @@ import type { PixelSkinZipInstallRequest, PixelSkinSlot, PixelTerminalBorderSkinId, + RemarkDraft, + RemarkPatch, UpdateStatus, SessionBounds, SessionEvent, @@ -60,6 +65,15 @@ ipcRenderer.on(IPC.windowOpenUpdates, () => { }); const api: CanvasTTYApi = { + mascots: { + list: () => ipcRenderer.invoke(IPC.mascotsList), + start: (image) => ipcRenderer.invoke(IPC.mascotsStart, image), + link: (projectId, sessionId) => ipcRenderer.invoke(IPC.mascotsLink, projectId, sessionId), + fail: (projectId, message) => ipcRenderer.invoke(IPC.mascotsFail, projectId, message), + retry: (projectId) => ipcRenderer.invoke(IPC.mascotsRetry, projectId), + openLog: (projectId) => ipcRenderer.invoke(IPC.mascotsOpenLog, projectId), + onChanged: (listener) => subscribe(IPC.mascotsChanged, listener) + }, diagnostics: { configuration: () => ipcRenderer.invoke(IPC.diagnosticsConfiguration), send: (description, attachment) => ipcRenderer.invoke(IPC.diagnosticsSend, description, attachment), @@ -120,6 +134,15 @@ const api: CanvasTTYApi = { media: { read: (path: string) => ipcRenderer.invoke(IPC.mediaRead, path) }, + files: { + listRoots: () => ipcRenderer.invoke(IPC.filesListRoots), + registerRoot: (reference: FileRootReference) => ipcRenderer.invoke(IPC.filesRegisterRoot, reference), + openFolder: () => ipcRenderer.invoke(IPC.filesOpenFolder), + list: (rootId: string, relativePath: string) => ipcRenderer.invoke(IPC.filesList, rootId, relativePath), + read: (rootId: string, relativePath: string) => ipcRenderer.invoke(IPC.filesRead, rootId, relativePath), + search: (rootId: string, query: string) => ipcRenderer.invoke(IPC.filesSearch, rootId, query), + closeRoot: (rootId: string) => ipcRenderer.invoke(IPC.filesCloseRoot, rootId) + }, materials: { snapshot: () => ipcRenderer.invoke(IPC.materialsSnapshot), addFiles: (files: File[], point: Point) => ipcRenderer.invoke( @@ -136,10 +159,14 @@ const api: CanvasTTYApi = { reveal: (id: string) => ipcRenderer.invoke(IPC.materialsReveal, id), relink: (id: string) => ipcRenderer.invoke(IPC.materialsRelink, id), acceptMove: (id: string) => ipcRenderer.invoke(IPC.materialsAcceptMove, id), - addRemark: (draft: unknown) => ipcRenderer.invoke(IPC.materialsAddRemark, draft), - updateRemark: (id: string, patch: unknown) => ipcRenderer.invoke(IPC.materialsUpdateRemark, id, patch), + addRemark: (draft: RemarkDraft) => ipcRenderer.invoke(IPC.materialsAddRemark, draft), + updateRemark: (id: string, patch: RemarkPatch) => ipcRenderer.invoke(IPC.materialsUpdateRemark, id, patch), deleteRemark: (id: string) => ipcRenderer.invoke(IPC.materialsDeleteRemark, id), + previewHandoff: (draft: HandoffDraft) => ipcRenderer.invoke(IPC.materialsPreviewHandoff, draft), + sendHandoff: (draft: HandoffDraft) => ipcRenderer.invoke(IPC.materialsSendHandoff, draft), + pickResultsFolder: (sessionId: string) => ipcRenderer.invoke(IPC.materialsPickResultsFolder, sessionId), onChanged: (listener: (snapshot: MaterialsSnapshot) => void) => subscribe(IPC.materialsChanged, listener) + }, limits: { get: () => ipcRenderer.invoke(IPC.limitsGet) @@ -149,6 +176,9 @@ const api: CanvasTTYApi = { set: (secretId: string, value: string) => ipcRenderer.invoke(IPC.providerSecretsSet, secretId, value), clear: (secretId: string) => ipcRenderer.invoke(IPC.providerSecretsClear, secretId) }, + usageHistory: { + get: () => ipcRenderer.invoke(IPC.usageHistoryGet) + }, plugins: { list: () => ipcRenderer.invoke(IPC.pluginsList), search: (query: string) => ipcRenderer.invoke(IPC.pluginsSearch, query), @@ -262,6 +292,7 @@ const api: CanvasTTYApi = { ipcRenderer.sendSync(IPC.canvasNavigationOwnerWheel, { clientX, clientY }); }, setShortcutCaptureActive: (active: boolean) => ipcRenderer.send(IPC.canvasNavigationShortcutCapture, active), + setTerminalEditFocus: (active: boolean) => ipcRenderer.send(IPC.canvasNavigationTerminalEditFocus, active), setPointerBindingState: (input: CanvasNavigationPointerBindingInput) => ( ipcRenderer.send(IPC.canvasNavigationPointerBinding, input) ), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a494320a..cb72f648 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9,11 +9,14 @@ import type { BrowserSnapshot, CameraState, CanvasRegion, + FileCard, + FileRootDescriptor, GithubPluginSearchResult, HomeAccentColors, HomeGridSize, HomeWidgetPlacement, InstalledPlugin, + MascotProjectSummary, LaunchProfileId, MaterialsAddResult, LaunchRole, @@ -58,7 +61,15 @@ import { UpdateNotice } from "./components/UpdateNotice"; import { resolveAppearanceSettings } from "./features/settings/appearanceSettings"; import { persistSettingsUpdate } from "./features/settings/persistSettings"; import { PluginBrowserOpenQueue } from "./features/plugins/PluginBrowserOpenQueue"; +import { + EMPTY_FILE_CARD_RUNTIME, + type FileCardRuntimeState +} from "./features/files/fileCardRuntime"; +import { fileCardToRootReference, isFileRootUsable } from "./features/files/fileCardRestore"; +import { matchQuickOpen, toggleExpandedFolder } from "./features/files/fileTree"; +import type { FileSessionOption } from "./features/files/FileBrowserCard"; import { GitRiskNotice } from "./features/terminal/GitRiskNotice"; +import { UsageHistoryPanel } from "./features/usage/UsageHistoryPanel"; import { WorkspaceCanvas } from "./features/workspace/WorkspaceCanvas"; import { createCameraStore } from "./features/workspace/cameraStore"; import { isPixelSkinThemeId } from "./features/skins/skinCatalog"; @@ -81,18 +92,22 @@ import { isShortcutCaptureTarget, matchesPointerShortcut, matchesShortcut, - shouldKeepNativeKeyboardInput + shouldKeepNativeKeyboardInput, + trackTerminalEditFocus } from "./lib/shortcuts"; import { homeGridPixelSize, homeLayoutFitsGrid, placeHomeWidget } from "./features/home/homeLayout"; import { boundsInsideRegion, translateBounds } from "./features/workspace/canvasRegions"; import { DEFAULT_SESSION_SIZE, findNearHomeSessionPosition } from "./features/workspace/sessionPlacement"; import { useMaterials } from "./features/materials/useMaterials"; +const HandoffDialog = lazy(() => + import("./features/materials/HandoffDialog").then((module) => ({ default: module.HandoffDialog }))); import { addResultNeedsNotice, materialFailureKey, materialRejectionKey, type MaterialCommand } from "./features/materials/materialCardModel"; +import { remarkNeedsWork } from "./features/materials/materialRemarksModel"; interface HomeEditDraft { homeGridSize: HomeGridSize; @@ -168,6 +183,7 @@ const FALLBACK_SETTINGS: AppSettings = { canvasRegions: [], stickyNotes: [], pluginCanvas: [], + fileCards: [], browserCanvas: null, browserAgentAccess: true, browserShowAgentPresence: true, @@ -200,6 +216,7 @@ const EMPTY_BROWSER_SNAPSHOT: BrowserSnapshot = { const DEFAULT_FOCUS_ZOOM = 0.92; const PLUGIN_CANVAS_FOCUS_ZOOM = 1; +const FILE_CARD_FOCUS_ZOOM = 1; function startHomeMediaRead( path: string, @@ -303,6 +320,20 @@ export function App(): React.JSX.Element { const [agentAvailability, setAgentAvailability] = useState(null); const [mediaData, setMediaData] = useState(null); const [plugins, setPlugins] = useState([]); + const [mascotProjects, setMascotProjects] = useState([]); + + useEffect(() => { + let active = true; + const unsubscribe = window.canvasTTY.mascots.onChanged((projects) => { + if (!active) return; + setMascotProjects(projects); + if (projects.some((project) => project.status === "ready")) { + void window.canvasTTY.plugins.list().then((installed) => { if (active) setPlugins(installed); }).catch(() => undefined); + } + }); + void window.canvasTTY.mascots.list().then((projects) => { if (active) setMascotProjects(projects); }).catch(() => undefined); + return () => { active = false; unsubscribe(); }; + }, []); const [browser, setBrowser] = useState(EMPTY_BROWSER_SNAPSHOT); // The camera lives in a store, not in state: a pan or zoom must not render the application tree. const [cameraStore] = useState(() => createCameraStore(homeCamera(DEFAULT_HOME_GRID_SIZE))); @@ -319,11 +350,22 @@ export function App(): React.JSX.Element { const settingsRef = useRef(settings); settingsRef.current = settings; const pluginBrowserOpenQueueRef = useRef(new PluginBrowserOpenQueue()); + /** Non-persisted Files-card runtime (root handle + loaded listings), keyed by card id. */ + const [fileCardRuntime, setFileCardRuntime] = useState>({}); + const fileCardRuntimeRef = useRef(fileCardRuntime); + fileCardRuntimeRef.current = fileCardRuntime; + /** Monotonic per-card quick-open sequence, so a stale search cannot overwrite a newer one. */ + const fileSearchSequence = useRef>({}); + /** Monotonic per-card read sequence, so a slow earlier read cannot overwrite a newer one. */ + const fileReadSequence = useRef>({}); const [launchProvider, setLaunchProvider] = useState(null); const [launchPosition, setLaunchPosition] = useState(null); const [settingsOpen, setSettingsOpen] = useState(false); const [openUpdatesRequest, setOpenUpdatesRequest] = useState(0); const [shortcutReferenceOpen, setShortcutReferenceOpen] = useState(false); + const [usageHistoryOpen, setUsageHistoryOpen] = useState(false); + /** App root outside the canvas transform; overlays portal here to inherit the theme tokens. */ + const [overlayHost, setOverlayHost] = useState(null); const [homeEditDraft, setHomeEditDraft] = useState(null); const [activeSessionId, setActiveSessionId] = useState(null); const [browserSelected, setBrowserSelected] = useState(false); @@ -350,9 +392,16 @@ export function App(): React.JSX.Element { }); const showToast = useCallback((message: string): void => setToast(message), []); + const fileSessionOptions = useMemo( + () => sessions.map((session) => ({ sessionId: session.id, label: session.title })), + [sessions] + ); const materials = useMaterials(); const materialsRef = useRef(materials.materials); materialsRef.current = materials.materials; + const [handoffRemarkIds, setHandoffRemarkIds] = useState(null); + const [lastHandoffSessionId, setLastHandoffSessionId] = useState(null); + const openUpdates = useCallback((): void => { setSettingsOpen(true); @@ -394,6 +443,11 @@ export function App(): React.JSX.Element { return () => { live = false; unsubscribe(); }; }, []); + const changeUsageHistoryOpen = useCallback((open: boolean): void => { + if (open) setSettingsOpen(false); + setUsageHistoryOpen(open); + }, []); + useEffect(() => { browserCanvasRef.current = settings.browserCanvas; }, [settings.browserCanvas]); @@ -597,7 +651,8 @@ export function App(): React.JSX.Element { requestedCenter?: Point, role: LaunchRole = "agent", launchOptions?: Record, - environment?: SessionEnvironmentChoice + environment?: SessionEnvironmentChoice, + initial?: { initialPrompt: string; initialImagePath: string } ): Promise => { const currentSettings = settingsRef.current; const pixelSkin = isPixelSkinThemeId(currentSettings.terminalBorderSkin) @@ -626,7 +681,8 @@ export function App(): React.JSX.Element { try { const session = await window.canvasTTY.terminal.create({ provider, profile, cwd, position, role, ...(launchOptions ? { launchOptions } : {}), - ...(environment ? { environment } : {}) + ...(environment ? { environment } : {}), + ...(initial ?? {}) }); const sizedSession = pixelSkin ? { ...session, size: { ...cardSize } } : session; if (pixelSkin) window.canvasTTY.terminal.setBounds(session.id, { position, size: sizedSession.size }); @@ -869,13 +925,18 @@ export function App(): React.JSX.Element { const stickyNotes = settingsRef.current.stickyNotes.map((note) => boundsInsideRegion(note, previous) ? { ...note, ...translateBounds(note, delta) } : note); + const fileCards = settingsRef.current.fileCards.map((card) => boundsInsideRegion(card, previous) + ? { ...card, ...translateBounds(card, delta) } + : card); for (const material of materialsRef.current) { if (boundsInsideRegion(material, previous)) materials.setBounds(material.id, translateBounds(material, delta)); } + setSessions(movedSessions); patch.pluginCanvas = pluginCanvas; patch.browserCanvas = browserCanvas; patch.stickyNotes = stickyNotes; + patch.fileCards = fileCards; browserCanvasRef.current = browserCanvas; } } @@ -980,11 +1041,24 @@ export function App(): React.JSX.Element { materials.deleteRemark(id) ), [materials]); - const remarkAction = useCallback((remarkId: string, action: "delete"): void => { + const remarkAction = useCallback((remarkId: string, action: "delete" | "send"): void => { if (action === "delete") { void deleteRemark(remarkId).catch(() => showToast(t(settingsRef.current.locale, "materialFailureUnavailable"))); + } else if (action === "send") { + const remark = materials.snapshot.remarks.find((candidate) => candidate.id === remarkId); + if (remark && remarkNeedsWork(remark)) setHandoffRemarkIds([remarkId]); } - }, [deleteRemark, showToast]); + }, [deleteRemark, materials.snapshot.remarks, showToast]); + + const sendMaterialRemarks = useCallback((materialId: string): void => { + setHandoffRemarkIds(materials.snapshot.remarks + .filter((remark) => remark.target.materialId === materialId && remarkNeedsWork(remark)) + .map((remark) => remark.id)); + }, [materials.snapshot.remarks]); + + const sendAllRemarks = useCallback((): void => { + setHandoffRemarkIds(materials.snapshot.remarks.filter(remarkNeedsWork).map((remark) => remark.id)); + }, [materials.snapshot.remarks]); const deleteCanvasRegion = useCallback((id: string): void => { const canvasRegions = settingsRef.current.canvasRegions.filter((region) => region.id !== id); @@ -993,6 +1067,333 @@ export function App(): React.JSX.Element { void saveSettings({ canvasRegions }); }, [saveSettings]); + const updateFileCardRuntime = useCallback(( + id: string, + update: (state: FileCardRuntimeState) => Partial + ): void => { + setFileCardRuntime((current) => { + const base = current[id] ?? EMPTY_FILE_CARD_RUNTIME; + return { ...current, [id]: { ...base, ...update(base) } }; + }); + }, []); + + const writeFileCards = useCallback((fileCards: FileCard[]): void => { + settingsRef.current = { ...settingsRef.current, fileCards }; + setSettings((current) => ({ ...current, fileCards })); + void saveSettings({ fileCards }); + }, [saveSettings]); + + const updateFileCard = useCallback((id: string, patch: Partial): void => { + writeFileCards(settingsRef.current.fileCards.map((card) => ( + card.id === id ? { ...card, ...patch } : card + ))); + }, [writeFileCards]); + + /** Releases a card's currently registered root, if any. Safe to call when none. */ + const releaseFileCardRoot = useCallback((id: string): void => { + const rootId = fileCardRuntimeRef.current[id]?.root?.rootId ?? null; + if (rootId) void window.canvasTTY.files.closeRoot(rootId).catch(() => undefined); + }, []); + + const loadFileRoot = useCallback(async (id: string, descriptor: FileRootDescriptor): Promise => { + updateFileCardRuntime(id, () => ({ + root: descriptor, + rootUnavailable: !descriptor.available, + loading: descriptor.available, + error: null, + entries: [], + childrenByDirectory: {}, + readResult: null, + quickOpenQuery: "", + quickOpenResults: [] + })); + if (!descriptor.available) return; + try { + const entries = await window.canvasTTY.files.list(descriptor.rootId, ""); + updateFileCardRuntime(id, () => ({ entries, loading: false })); + } catch (error) { + updateFileCardRuntime(id, () => ({ loading: false, error: fileErrorMessage(error) })); + } + }, [updateFileCardRuntime]); + + const chooseFileFolder = useCallback((id: string): void => { + void (async () => { + try { + const descriptor = await window.canvasTTY.files.openFolder(); + if (!descriptor) return; + // The card may have closed while the native dialog was open; release the + // freshly registered root instead of leaking it. + if (!settingsRef.current.fileCards.some((card) => card.id === id)) { + void window.canvasTTY.files.closeRoot(descriptor.rootId).catch(() => undefined); + return; + } + releaseFileCardRoot(id); + fileReadSequence.current[id] = (fileReadSequence.current[id] ?? 0) + 1; + updateFileCard(id, { + root: { rootType: "folder" }, + label: descriptor.label, + folderPath: descriptor.folderPath ?? null, + activeFile: null, + expandedFolders: [] + }); + await loadFileRoot(id, descriptor); + } catch (error) { + updateFileCardRuntime(id, () => ({ loading: false, error: fileErrorMessage(error) })); + } + })(); + }, [loadFileRoot, releaseFileCardRoot, updateFileCard, updateFileCardRuntime]); + + const registerFileSession = useCallback((id: string, sessionId: string): void => { + void (async () => { + try { + const descriptor = await window.canvasTTY.files.registerRoot({ rootType: "session", sessionId }); + if (!descriptor) { + updateFileCardRuntime(id, () => ({ rootUnavailable: true, loading: false, error: null })); + return; + } + if (!settingsRef.current.fileCards.some((card) => card.id === id)) { + void window.canvasTTY.files.closeRoot(descriptor.rootId).catch(() => undefined); + return; + } + releaseFileCardRoot(id); + fileReadSequence.current[id] = (fileReadSequence.current[id] ?? 0) + 1; + updateFileCard(id, { + root: { rootType: "session", sessionId }, + label: descriptor.label, + folderPath: null, + activeFile: null, + expandedFolders: [] + }); + await loadFileRoot(id, descriptor); + } catch (error) { + updateFileCardRuntime(id, () => ({ loading: false, error: fileErrorMessage(error) })); + } + })(); + }, [loadFileRoot, releaseFileCardRoot, updateFileCard, updateFileCardRuntime]); + + const openFileDirectory = useCallback((id: string, relativePath: string): void => { + const card = settingsRef.current.fileCards.find((candidate) => candidate.id === id); + const runtime = fileCardRuntimeRef.current[id]; + const root = runtime?.root ?? null; + if (!card || !root || runtime?.rootUnavailable) return; + const wasExpanded = card.expandedFolders.includes(relativePath); + updateFileCard(id, { expandedFolders: toggleExpandedFolder(card.expandedFolders, relativePath) }); + if (wasExpanded || runtime.childrenByDirectory[relativePath] !== undefined) return; + updateFileCardRuntime(id, () => ({ loading: true, error: null })); + void window.canvasTTY.files.list(root.rootId, relativePath).then( + (entries) => updateFileCardRuntime(id, (state) => ({ + loading: false, + childrenByDirectory: { ...state.childrenByDirectory, [relativePath]: entries } + })), + (error: unknown) => updateFileCardRuntime(id, () => ({ + loading: false, + error: fileErrorMessage(error) + })) + ); + }, [updateFileCard, updateFileCardRuntime]); + + const openFile = useCallback((id: string, relativePath: string): void => { + const runtime = fileCardRuntimeRef.current[id]; + const root = runtime?.root ?? null; + if (!root || runtime?.rootUnavailable) return; + updateFileCard(id, { activeFile: relativePath }); + // Clear the binding so a still-in-flight read cannot render the previous file. + updateFileCardRuntime(id, () => ({ readResult: null, loading: true, error: null })); + const sequence = (fileReadSequence.current[id] ?? 0) + 1; + fileReadSequence.current[id] = sequence; + void window.canvasTTY.files.read(root.rootId, relativePath).then( + (result) => { + if (fileReadSequence.current[id] !== sequence) return; + updateFileCardRuntime(id, () => ({ + loading: false, + readResult: { relativePath, result } + })); + }, + (error: unknown) => { + if (fileReadSequence.current[id] !== sequence) return; + updateFileCardRuntime(id, () => ({ + loading: false, + readResult: null, + error: fileErrorMessage(error) + })); + } + ); + }, [updateFileCard, updateFileCardRuntime]); + + const quickOpenFiles = useCallback((id: string, query: string): void => { + updateFileCardRuntime(id, () => ({ quickOpenQuery: query })); + const sequence = (fileSearchSequence.current[id] ?? 0) + 1; + fileSearchSequence.current[id] = sequence; + const runtime = fileCardRuntimeRef.current[id]; + const root = runtime?.root ?? null; + if (query.trim() === "" || !root || runtime?.rootUnavailable) { + updateFileCardRuntime(id, () => ({ quickOpenResults: [], loading: false })); + return; + } + updateFileCardRuntime(id, () => ({ loading: true, quickOpenResults: [], error: null })); + void window.canvasTTY.files.search(root.rootId, query).then( + (result) => { + if (fileSearchSequence.current[id] !== sequence) return; + updateFileCardRuntime(id, () => ({ + loading: false, + quickOpenResults: matchQuickOpen(result.relativePaths, query) + })); + }, + (error: unknown) => { + if (fileSearchSequence.current[id] !== sequence) return; + updateFileCardRuntime(id, () => ({ loading: false, error: fileErrorMessage(error) })); + } + ); + }, [updateFileCardRuntime]); + + /** + * Re-registers one persisted Files card's root on launch. A session root is + * resolved through its session id; a folder root is reopened by its persisted + * canonical path. A null/unavailable registration shows the explicit + * unavailable state and reads nothing; a card that never chose a root stays in + * the root-selection state instead. + */ + const restoreFileCard = useCallback(async (card: FileCard): Promise => { + const reference = fileCardToRootReference(card); + if (!reference) { + updateFileCardRuntime(card.id, () => ({ ...EMPTY_FILE_CARD_RUNTIME })); + return; + } + const stillOpen = (): boolean => settingsRef.current.fileCards.some((candidate) => candidate.id === card.id); + const releaseRoot = (rootId: string): void => { + void window.canvasTTY.files.closeRoot(rootId).catch(() => undefined); + }; + let descriptor: FileRootDescriptor | null = null; + try { + descriptor = await window.canvasTTY.files.registerRoot(reference); + } catch (error) { + if (!stillOpen()) return; + updateFileCardRuntime(card.id, () => ({ + ...EMPTY_FILE_CARD_RUNTIME, + rootUnavailable: true, + error: fileErrorMessage(error) + })); + return; + } + if (descriptor !== null && !stillOpen()) { + releaseRoot(descriptor.rootId); + return; + } + if (!isFileRootUsable(descriptor)) { + updateFileCardRuntime(card.id, () => ({ ...EMPTY_FILE_CARD_RUNTIME, rootUnavailable: true })); + return; + } + const root = descriptor; + updateFileCardRuntime(card.id, () => ({ ...EMPTY_FILE_CARD_RUNTIME, root, loading: true })); + try { + const entries = await window.canvasTTY.files.list(root.rootId, ""); + if (!stillOpen()) { + releaseRoot(root.rootId); + return; + } + updateFileCardRuntime(card.id, () => ({ entries, loading: false })); + } catch (error) { + if (!stillOpen()) return; + updateFileCardRuntime(card.id, () => ({ loading: false, error: fileErrorMessage(error) })); + return; + } + for (const folder of card.expandedFolders) { + try { + const children = await window.canvasTTY.files.list(root.rootId, folder); + if (!stillOpen()) { + releaseRoot(root.rootId); + return; + } + updateFileCardRuntime(card.id, (state) => ({ + childrenByDirectory: { ...state.childrenByDirectory, [folder]: children } + })); + } catch { + // A folder that disappeared stays unloaded; the rest of the tree restores. + } + } + if (card.activeFile) { + const relativePath = card.activeFile; + updateFileCardRuntime(card.id, () => ({ loading: true })); + try { + const result = await window.canvasTTY.files.read(root.rootId, relativePath); + if (!stillOpen()) { + releaseRoot(root.rootId); + return; + } + updateFileCardRuntime(card.id, () => ({ + loading: false, + readResult: { relativePath, result } + })); + } catch (error) { + if (!stillOpen()) return; + updateFileCardRuntime(card.id, () => ({ + loading: false, + readResult: null, + error: fileErrorMessage(error) + })); + } + } + }, [updateFileCardRuntime]); + + const fileCardsRestored = useRef(false); + useEffect(() => { + if (!ready || fileCardsRestored.current) return; + fileCardsRestored.current = true; + for (const card of settingsRef.current.fileCards) void restoreFileCard(card); + }, [ready, restoreFileCard]); + + const changeFileCardBounds = useCallback((id: string, bounds: SessionBounds): void => { + updateFileCard(id, { position: bounds.position, size: bounds.size }); + }, [updateFileCard]); + + const closeFileCard = useCallback((id: string): void => { + const rootId = fileCardRuntimeRef.current[id]?.root?.rootId ?? null; + delete fileSearchSequence.current[id]; + delete fileReadSequence.current[id]; + setFileCardRuntime((current) => { + if (!(id in current)) return current; + const next = { ...current }; + delete next[id]; + return next; + }); + writeFileCards(settingsRef.current.fileCards.filter((card) => card.id !== id)); + if (rootId) void window.canvasTTY.files.closeRoot(rootId).catch(() => undefined); + }, [writeFileCards]); + + const focusFileCard = useCallback((id: string): void => { + const card = settingsRef.current.fileCards.find((candidate) => candidate.id === id); + if (!card) return; + setActiveSessionId(null); + setBrowserSelected(false); + isHomeCamera.current = false; + setCamera(focusCamera(card.position, card.size, FILE_CARD_FOCUS_ZOOM)); + }, []); + + const openFilesCard = useCallback((position?: Point): void => { + const id = crypto.randomUUID(); + const size = { width: 780, height: 520 }; + const index = settingsRef.current.fileCards.length; + const homeSize = homeGridPixelSize(settings.homeGridSize); + const card: FileCard = { + id, + root: { rootType: "folder" }, + label: null, + folderPath: null, + activeFile: null, + expandedFolders: [], + position: position + ? centeredWindowPosition(position, size) + : { x: homeSize.width + 160 + (index % 2) * 820, y: Math.floor(index / 2) * 560 + 20 }, + size + }; + writeFileCards([...settingsRef.current.fileCards, card]); + setFileCardRuntime((current) => ({ ...current, [id]: EMPTY_FILE_CARD_RUNTIME })); + setActiveSessionId(null); + setBrowserSelected(false); + isHomeCamera.current = false; + setCamera(focusCamera(card.position, card.size, FILE_CARD_FOCUS_ZOOM)); + }, [settings.homeGridSize, writeFileCards]); + const disposePluginCanvas = useCallback((id: string): void => { void saveSettings({ pluginCanvas: settingsRef.current.pluginCanvas.filter((instance) => instance.id !== id) }); }, [saveSettings]); @@ -1038,6 +1439,26 @@ export function App(): React.JSX.Element { setCamera(focusCamera(browserCanvas.position, browserCanvas.size)); }, [persistSettings, sessions.length, settings.homeGridSize, settings.locale, settings.pluginCanvas.length]); + const createMascot = useCallback(async (file: File): Promise => { + if (!agentAvailability?.codex) throw new Error(settings.locale === "ru" ? "Codex CLI недоступен." : "Codex CLI is unavailable."); + // The creation button explicitly describes its YOLO launch to the person. + await acknowledgeDanger("codex"); + if (!settingsRef.current.browserAgentAccess) await saveSettings({ browserAgentAccess: true }); + const launch = await window.canvasTTY.mascots.start(new Uint8Array(await file.arrayBuffer())); + try { + const session = await createSession("codex", "yolo", launch.cwd, undefined, "agent", undefined, undefined, + { initialPrompt: launch.prompt, initialImagePath: launch.imagePath }); + if (session.status === "failed") throw new Error(session.failureDetails ?? "Codex did not start."); + await window.canvasTTY.mascots.link(launch.project.id, session.id); + setSettingsOpen(false); + } catch (cause) { + await window.canvasTTY.mascots.fail(launch.project.id, cause instanceof Error ? cause.message : String(cause)); + throw cause; + } + try { await openBrowser(launch.previewUrl); } + catch (cause) { showToast(cause instanceof Error ? cause.message : String(cause)); } + }, [agentAvailability?.codex, acknowledgeDanger, createSession, openBrowser, saveSettings, settings.locale, showToast]); + useEffect(() => { return window.canvasTTY.plugins.onBrowserOpenRequested((request) => { void pluginBrowserOpenQueueRef.current.enqueue(() => openBrowser(request.url)).then( @@ -1390,15 +1811,34 @@ export function App(): React.JSX.Element { await openPluginCanvasContribution(plugin, contribution); }, [openPluginCanvasContribution, toggleHomeWidget]); - useEffect(() => window.canvasTTY.plugins.onOpenCanvas((request) => { - const plugin = plugins.find((candidate) => candidate.manifest.id === request.pluginId); - const contribution = plugin?.manifest.contributions.find((candidate) => candidate.id === request.contributionId); - if (!plugin || !contribution || contribution.kind !== "canvas-app" || !plugin.enabled) { - showToast(t(settings.locale, "pluginActionFailed")); + const openMascot = useCallback(async (project: MascotProjectSummary): Promise => { + if (project.status === "ready" && project.pluginId) { + const installed = await window.canvasTTY.plugins.list(); + setPlugins(installed); + const plugin = installed.find((item) => item.manifest.id === project.pluginId); + const contribution = plugin?.manifest.contributions.find((item) => item.kind === "canvas-app"); + if (!plugin || !contribution) throw new Error("Mascot plugin is unavailable."); + await openPluginCanvasContribution(plugin, contribution); return; } - void openPluginCanvasContribution(plugin, contribution, request.sourceCanvasInstanceId) - .catch((error) => showToast(error instanceof Error ? error.message : t(settings.locale, "pluginActionFailed"))); + const session = sessionsRef.current.find((item) => item.id === project.sessionId); + if (!session) throw new Error(settings.locale === "ru" ? "Чат Codex больше не открыт." : "The Codex chat is no longer open."); + setSettingsOpen(false); + setActiveSessionId(session.id); + isHomeCamera.current = false; + setCamera(focusCamera(session.position, session.size)); + }, [openPluginCanvasContribution, settings.locale]); + + useEffect(() => window.canvasTTY.plugins.onOpenCanvas((request) => { + void (async () => { + const installed = plugins.find((candidate) => candidate.manifest.id === request.pluginId) + ? plugins : await window.canvasTTY.plugins.list(); + if (installed !== plugins) setPlugins(installed); + const plugin = installed.find((candidate) => candidate.manifest.id === request.pluginId); + const contribution = plugin?.manifest.contributions.find((candidate) => candidate.id === request.contributionId); + if (!plugin || !contribution || contribution.kind !== "canvas-app" || !plugin.enabled) throw new Error(t(settings.locale, "pluginActionFailed")); + await openPluginCanvasContribution(plugin, contribution, request.sourceCanvasInstanceId); + })().catch((error) => showToast(error instanceof Error ? error.message : t(settings.locale, "pluginActionFailed"))); }), [openPluginCanvasContribution, plugins, settings.locale, showToast]); const startHomeEditor = useCallback((): void => { @@ -1425,7 +1865,7 @@ export function App(): React.JSX.Element { useEffect(() => { const performShortcut = (shortcut: "home" | "renameWindow" | "toggleFullscreen"): void => { if (shortcut === "toggleFullscreen") { - if (settingsOpen || launchProvider !== null || pendingTerminalUrl !== null || homeEditDraft) return; + if (settingsOpen || launchProvider !== null || pendingTerminalUrl !== null || homeEditDraft || handoffRemarkIds !== null) return; const id = fullscreenSessionId ?? activeSessionId; if (id) toggleSessionFullscreen(id); return; @@ -1443,7 +1883,9 @@ export function App(): React.JSX.Element { const handleShortcut = (event: KeyboardEvent): void => { if (shortcutReferenceOpen) return; if (handleMacNativeSelectAll(event, window.canvasTTY.window.isMacOS)) return; + if (handoffRemarkIds !== null) return; if (shouldKeepNativeKeyboardInput(event.target, window.canvasTTY.window.isMacOS, event)) return; + if (usageHistoryOpen) return; if (event.repeat || isShortcutCaptureTarget(event.target) || isRenameInputTarget(event.target)) return; if (matchesShortcut(event, settings.shortcuts.toggleFullscreen)) { event.preventDefault(); @@ -1465,7 +1907,7 @@ export function App(): React.JSX.Element { }; const handlePointerShortcut = (event: PointerEvent): void => { - if (shortcutReferenceOpen) return; + if (shortcutReferenceOpen || usageHistoryOpen || handoffRemarkIds !== null) return; if (isShortcutCaptureTarget(event.target) || isRenameInputTarget(event.target)) return; const action = matchesPointerShortcut(event, settings.shortcuts.home) ? "home" @@ -1482,11 +1924,15 @@ export function App(): React.JSX.Element { window.addEventListener("keydown", handleShortcut, true); window.addEventListener("pointerdown", handlePointerShortcut, true); + const stopTerminalFocus = window.canvasTTY.window.isMacOS + ? trackTerminalEditFocus(document, (focused) => window.canvasTTY.canvasNavigation.setTerminalEditFocus(focused)) + : () => undefined; return () => { window.removeEventListener("keydown", handleShortcut, true); window.removeEventListener("pointerdown", handlePointerShortcut, true); + stopTerminalFocus(); }; - }, [activeSessionId, fullscreenSessionId, goHome, homeEditDraft, launchProvider, pendingTerminalUrl, settings.locale, settings.shortcuts, settingsOpen, shortcutReferenceOpen, showToast, toggleSessionFullscreen]); + }, [activeSessionId, fullscreenSessionId, goHome, handoffRemarkIds, homeEditDraft, launchProvider, pendingTerminalUrl, settings.locale, settings.shortcuts, settingsOpen, shortcutReferenceOpen, showToast, usageHistoryOpen, toggleSessionFullscreen]); const appearance = resolveAppearanceSettings(settings); const rootClasses = useMemo( @@ -1522,9 +1968,15 @@ export function App(): React.JSX.Element { }, [agentAvailability, homeEditDraft, settings]); return ( -
+
+
{!ready &&
{t(settings.locale, "loading")}
} {ready && { + void openBrowser(href).catch((error: unknown) => { + showToast(error instanceof Error ? error.message : t(settings.locale, "browserActionFailed")); + }); + }} materials={materials.materials} onAddMaterialFiles={addMaterialFiles} onPickMaterials={pickMaterials} @@ -1601,8 +2069,11 @@ export function App(): React.JSX.Element { onRemoveMaterial={removeMaterial} onMaterialCommand={runMaterialCommand} remarks={materials.remarks} + handoffs={materials.snapshot.handoffs} onAddRemark={addRemark} onRemarkAction={remarkAction} + onSendMaterialRemarks={sendMaterialRemarks} + onSendAllRemarks={sendAllRemarks} />}
@@ -1666,7 +2137,24 @@ export function App(): React.JSX.Element { onToggleHomeWidget={toggleHomeWidget} onEditHome={startHomeEditor} onOpenBrowser={openBrowser} + onCreateMascot={createMascot} + mascotProjects={mascotProjects} + onOpenMascot={openMascot} + onRetryMascot={(projectId) => window.canvasTTY.mascots.retry(projectId)} + onOpenMascotLog={(projectId) => window.canvasTTY.mascots.openLog(projectId)} /> + {handoffRemarkIds !== null && setHandoffRemarkIds(null)} + onSent={(handoff) => setLastHandoffSessionId(handoff.sessionId)} + onFocusSession={focusSession} + />} {closedGitRisks.length > 0 && (
@@ -1697,6 +2185,10 @@ export function App(): React.JSX.Element { ); } +function fileErrorMessage(error: unknown): string { + return error instanceof Error && error.message.length > 0 ? error.message : String(error); +} + function centeredWindowPosition(point: Point, size: { width: number; height: number }): Point { return { x: point.x - size.width / 2, diff --git a/src/renderer/src/assets/icons/lucide/send.svg b/src/renderer/src/assets/icons/lucide/send.svg new file mode 100644 index 00000000..001843b9 --- /dev/null +++ b/src/renderer/src/assets/icons/lucide/send.svg @@ -0,0 +1,14 @@ + + + + diff --git a/src/renderer/src/components/UiIcon.tsx b/src/renderer/src/components/UiIcon.tsx index c6eab550..27438ad1 100644 --- a/src/renderer/src/components/UiIcon.tsx +++ b/src/renderer/src/components/UiIcon.tsx @@ -32,6 +32,7 @@ import plusIcon from "../assets/icons/lucide/plus.svg"; import paletteIcon from "../assets/icons/lucide/palette.svg"; import pencilIcon from "../assets/icons/lucide/pencil.svg"; import searchIcon from "../assets/icons/lucide/search.svg"; +import sendIcon from "../assets/icons/lucide/send.svg"; import settingsIcon from "../assets/icons/lucide/settings.svg"; import slidersHorizontalIcon from "../assets/icons/lucide/sliders-horizontal.svg"; import stickyNoteIcon from "../assets/icons/lucide/sticky-note.svg"; @@ -86,7 +87,8 @@ export type UiIconName = | "working" | "attention" | "error" - | "done"; + | "done" + | "send"; interface UiIconProps { name: UiIconName; @@ -138,7 +140,8 @@ const ICONS: Record = { working: workingIcon, attention: attentionIcon, error: errorIcon, - done: checkIcon + done: checkIcon, + send: sendIcon }; export function UiIcon({ name, size = 24 }: UiIconProps): React.JSX.Element { diff --git a/src/renderer/src/features/files/CodeView.tsx b/src/renderer/src/features/files/CodeView.tsx new file mode 100644 index 00000000..c258cb59 --- /dev/null +++ b/src/renderer/src/features/files/CodeView.tsx @@ -0,0 +1,19 @@ +import { highlightCode } from "./codeHighlight.ts"; + +export interface CodeViewProps { + content: string; + language: string | null; +} + +/** + * Renders code with lowlight highlighting for registered languages and falls + * back to plain monospace text for unknown languages. + */ +export function CodeView({ content, language }: CodeViewProps): React.JSX.Element { + const highlighted = highlightCode(content, language); + return ( +
+      {highlighted ?? content}
+    
+ ); +} diff --git a/src/renderer/src/features/files/FileBrowserCard.tsx b/src/renderer/src/features/files/FileBrowserCard.tsx new file mode 100644 index 00000000..4f3cf54f --- /dev/null +++ b/src/renderer/src/features/files/FileBrowserCard.tsx @@ -0,0 +1,604 @@ +import { Suspense, lazy, useEffect, useMemo, useRef, useState } from "react"; +import type { + FileEntry, + FileRootDescriptor, + LocaleId, + Point, + SessionBounds +} from "../../../../shared/contracts"; +import { UiIcon } from "../../components/UiIcon"; +import { t } from "../../lib/i18n"; +import { filesLayerId } from "../workspace/canvasSelectionGesture"; +import { filesCanvasWidgetId } from "../workspace/canvasWidgetFocus"; +import { snapMove, snapResize, type ResizeDirection } from "../workspace/snap"; +import { buildFileTree, flattenFileTree, type FileTreeNode } from "./fileTree"; +import { resolveActiveFileRead, type FileReadBinding } from "./fileViewer"; + +// The rich renderers pull in react-markdown / rehype / lowlight. Lazy-load them +// so their weight stays out of the app's static import graph (see +// tests/renderer-bundle-splitting.test.mjs) and is only fetched when a rich file +// is actually opened. +const MarkdownView = lazy(() => import("./MarkdownView").then((module) => ({ default: module.MarkdownView }))); +const CodeView = lazy(() => import("./CodeView").then((module) => ({ default: module.CodeView }))); + +/** A selectable terminal session working directory offered as a Files root. */ +export interface FileSessionOption { + sessionId: string; + label: string; +} + +/** + * The Files card is strictly presentational: every piece of data and every + * action arrives through props. IPC, tree loading, root registration, search, + * and persistence are owned by the parent (WorkspaceCanvas / App). + */ +export interface FileBrowserCardProps { + /** Stable identity; the card renders `data-canvas-layer-id="files:"`. */ + cardId: string; + bounds: SessionBounds; + /** Opaque root handle; null means the card still needs a root selected. */ + root: FileRootDescriptor | null; + /** Sessions offered as session roots while no root is selected. */ + sessionOptions?: readonly FileSessionOption[]; + /** Root directory listing. */ + entries: readonly FileEntry[]; + /** Loaded child listings keyed by directory relative path. */ + childrenByDirectory?: Readonly>; + /** Relative paths of expanded tree folders. */ + expandedFolders: readonly string[]; + /** Relative path of the file shown in the viewer, or null. */ + activeFile: string | null; + /** + * Read result together with the relative path it was read for. Content is + * shown only when `readResult.relativePath === activeFile`; any mismatch (a + * read still in flight for a newly selected file) renders loading instead. + */ + readResult: FileReadBinding | null; + /** True while a listing, read, or search is in flight. */ + loading: boolean; + /** Error text for the last failed operation, or null. */ + error: string | null; + /** True when a restored root can no longer be read. */ + rootUnavailable: boolean; + quickOpenQuery: string; + quickOpenResults: readonly string[]; + /** Increments when the parent requests focus for this card's quick-open input. */ + quickOpenFocusRequest?: number; + locale: LocaleId; + zoom: number; + stackIndex: number; + snapEnabled: boolean; + getSnapTargets(): readonly SessionBounds[]; + onChooseFolder(): void; + onRegisterSession(sessionId: string): void; + onOpenDirectory(relativePath: string): void; + onOpenFile(relativePath: string): void; + onQuickOpen(query: string): void; + onChangeBounds(bounds: SessionBounds): void; + onClose(): void; + /** Opens a link from a rendered markdown file. */ + onOpenLink?: (href: string) => void; + /** True while this card is part of the marquee selection. */ + groupSelected?: boolean; +} + +interface DragState { + pointerId: number; + startClient: Point; + startBounds: SessionBounds; + snapTargets: readonly SessionBounds[]; +} + +interface ResizeState extends DragState { + direction: ResizeDirection; +} + +const RESIZE_DIRECTIONS: ResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"]; +const EMPTY_CHILDREN: Readonly> = {}; + +export function FileBrowserCard({ + cardId, + bounds, + root, + sessionOptions = [], + entries, + childrenByDirectory = EMPTY_CHILDREN, + expandedFolders, + activeFile, + readResult, + loading, + error, + rootUnavailable, + quickOpenQuery, + quickOpenResults, + quickOpenFocusRequest = 0, + locale, + zoom, + stackIndex, + snapEnabled, + getSnapTargets, + onChooseFolder, + onRegisterSession, + onOpenDirectory, + onOpenFile, + onQuickOpen, + onChangeBounds, + onClose, + onOpenLink = () => {}, + groupSelected = false +}: FileBrowserCardProps): React.JSX.Element { + const dragState = useRef(null); + const resizeState = useRef(null); + const quickOpenInput = useRef(null); + const initialBounds = constrainFileBrowserResize({ position: bounds.position, size: bounds.size }, "se"); + const [position, setPosition] = useState(initialBounds.position); + const [size, setSize] = useState(initialBounds.size); + const liveBounds = useRef(initialBounds); + const summaryMode = zoom < 0.5; + const summaryScale = summaryMode ? Math.min(2.5, Math.max(1, 0.5 / zoom)) : 1; + + const tree: FileTreeNode[] = useMemo( + () => buildFileTree(entries, childrenByDirectory), + [childrenByDirectory, entries] + ); + const rows = useMemo( + () => flattenFileTree(tree, expandedFolders), + [expandedFolders, tree] + ); + const viewer = resolveActiveFileRead(activeFile, readResult); + const activeName = activeFile ? basename(activeFile) : null; + const treeTitle = root?.label ?? t(locale, "files"); + const headerDetail = activeName ?? (root ? t(locale, "filesNoFile") : t(locale, "filesSelectRoot")); + const trimmedQuery = quickOpenQuery.trim(); + const unsupportedText = viewer?.kind === "unsupported" + ? unsupportedReasonText(locale, viewer.reason) + : ""; + const textContent = viewer?.kind === "text" ? viewer.content : null; + const textRenderKind = viewer?.kind === "text" ? viewer.renderKind : null; + const textLanguage = viewer?.kind === "text" ? viewer.language : null; + const textRichDisabled = viewer?.kind === "text" ? viewer.richDisabled : false; + const richView = useMemo(() => { + if (textContent === null || textRichDisabled) return null; + if (textRenderKind === "markdown") return ; + if (textRenderKind === "code") return ; + return null; + }, [textContent, textRenderKind, textLanguage, textRichDisabled, onOpenLink]); + + useEffect(() => { + const next = constrainFileBrowserResize({ position: bounds.position, size: bounds.size }, "se"); + liveBounds.current = next; + setPosition(next.position); + setSize(next.size); + }, [bounds]); + + useEffect(() => { + if (quickOpenFocusRequest > 0) quickOpenInput.current?.focus(); + }, [quickOpenFocusRequest]); + + const applyBounds = (next: SessionBounds): void => { + liveBounds.current = next; + setPosition(next.position); + setSize(next.size); + }; + + const startDrag = (event: React.PointerEvent): void => { + if ((event.target as HTMLElement).closest("button, input, [data-file-action]")) return; + event.currentTarget.setPointerCapture(event.pointerId); + dragState.current = { + pointerId: event.pointerId, + startClient: { x: event.clientX, y: event.clientY }, + startBounds: liveBounds.current, + snapTargets: snapEnabled ? getSnapTargets() : [] + }; + }; + + const drag = (event: React.PointerEvent): void => { + const state = dragState.current; + if (!state || state.pointerId !== event.pointerId) return; + // A buttonless move is a hover, not a drag. + if (event.buttons === 0) return; + const rawPosition = { + x: state.startBounds.position.x + (event.clientX - state.startClient.x) / zoom, + y: state.startBounds.position.y + (event.clientY - state.startClient.y) / zoom + }; + applyBounds({ + position: snapEnabled ? snapMove(rawPosition, state.startBounds.size, state.snapTargets) : rawPosition, + size: state.startBounds.size + }); + }; + + const endDrag = (event: React.PointerEvent): void => { + if (dragState.current?.pointerId !== event.pointerId) return; + dragState.current = null; + onChangeBounds(liveBounds.current); + }; + + // A group drag takes pointer capture without a pointerup; drop local state so + // a later hover cannot act on it. + const cancelDrag = (): void => { + dragState.current = null; + }; + + const cancelResize = (): void => { + resizeState.current = null; + }; + + const startResize = (event: React.PointerEvent, direction: ResizeDirection): void => { + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + resizeState.current = { + pointerId: event.pointerId, + direction, + startClient: { x: event.clientX, y: event.clientY }, + startBounds: liveBounds.current, + snapTargets: snapEnabled ? getSnapTargets() : [] + }; + }; + + const resize = (event: React.PointerEvent): void => { + const state = resizeState.current; + if (!state || state.pointerId !== event.pointerId) return; + // A buttonless move is a hover, not a resize. + if (event.buttons === 0) return; + event.preventDefault(); + event.stopPropagation(); + const deltaX = (event.clientX - state.startClient.x) / zoom; + const deltaY = (event.clientY - state.startClient.y) / zoom; + const raw: SessionBounds = { + position: { + x: state.startBounds.position.x + (state.direction.includes("w") ? deltaX : 0), + y: state.startBounds.position.y + (state.direction.includes("n") ? deltaY : 0) + }, + size: { + width: state.startBounds.size.width + + (state.direction.includes("e") ? deltaX : 0) + - (state.direction.includes("w") ? deltaX : 0), + height: state.startBounds.size.height + + (state.direction.includes("s") ? deltaY : 0) + - (state.direction.includes("n") ? deltaY : 0) + } + }; + const constrained = constrainFileBrowserResize(raw, state.direction); + applyBounds(snapEnabled ? snapResize(constrained, state.direction, state.snapTargets) : constrained); + }; + + const endResize = (event: React.PointerEvent): void => { + if (resizeState.current?.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + resizeState.current = null; + onChangeBounds(liveBounds.current); + }; + + return ( +
+
+ + + + {treeTitle} + {headerDetail} + + +
+ + +
+
+ +
+ + +
+ {renderViewer()} +
+
+ + + + {RESIZE_DIRECTIONS.map((direction) => ( +
+ ); + + function renderViewer(): React.JSX.Element { + if (rootUnavailable) { + return ( + + ); + } + if (root === null) { + return ( +
+ + {t(locale, "filesSelectRoot")} +

{t(locale, "filesSelectRootHint")}

+ + {sessionOptions.length > 0 && ( + <> + {t(locale, "filesSessionRoot")} +
+ {sessionOptions.map((option) => ( + + ))} +
+ + )} +
+ ); + } + if (error !== null) { + return ; + } + if (activeFile === null) { + return ; + } + if (viewer === null) { + // The active file has no read bound to it yet: show loading instead of the + // previous file's content. + return ; + } + switch (viewer.kind) { + case "text": + return ( + <> + {viewer.truncated && ( +
+ + {t(locale, "filesTruncated")} + {viewer.sizeLabel} +
+ )} + {viewer.richDisabled ? ( + <> +
+ + {t(locale, "filesRichDisabled")} + {viewer.sizeLabel} +
+
{viewer.content}
+ + ) : richView !== null ? ( +
+ }> + {richView} + +
+ ) : ( +
{viewer.content}
+ )} + + ); + case "image": + return ( +
+ {activeName + {viewer.mediaType} · {viewer.sizeLabel} +
+ ); + case "unsupported": + return ( + + ); + case "too-large": + return ( + + ); + } + } +} + +function ViewerState({ icon, title, detail }: { + icon: React.ComponentProps["name"]; + title: string; + detail?: string; +}): React.JSX.Element { + return ( +
+ + {title} + {detail && {detail}} +
+ ); +} + +function TreeNotice({ icon, title, detail }: { + icon: React.ComponentProps["name"]; + title: string; + detail?: string; +}): React.JSX.Element { + return ( +
+ + {title} + {detail && {detail}} +
+ ); +} + +function unsupportedReasonText(locale: LocaleId, reason: "binary" | "not-permitted" | "unavailable"): string { + if (reason === "binary") return t(locale, "filesUnsupportedBinary"); + if (reason === "not-permitted") return t(locale, "filesUnsupportedNotPermitted"); + return t(locale, "filesUnsupportedUnavailable"); +} + +function basename(relativePath: string): string { + const index = relativePath.lastIndexOf("/"); + return index === -1 ? relativePath : relativePath.slice(index + 1); +} + +function constrainFileBrowserResize(bounds: SessionBounds, direction: ResizeDirection): SessionBounds { + const right = bounds.position.x + bounds.size.width; + const bottom = bounds.position.y + bounds.size.height; + const width = clamp(bounds.size.width, 460, 1_600); + const height = clamp(bounds.size.height, 340, 1_200); + return { + position: { + x: direction.includes("w") ? right - width : bounds.position.x, + y: direction.includes("n") ? bottom - height : bounds.position.y + }, + size: { width, height } + }; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} diff --git a/src/renderer/src/features/files/MarkdownView.tsx b/src/renderer/src/features/files/MarkdownView.tsx new file mode 100644 index 00000000..45b6416c --- /dev/null +++ b/src/renderer/src/features/files/MarkdownView.tsx @@ -0,0 +1,13 @@ +import type { JSX } from "react"; +import { createMarkdownElement } from "./markdownPipeline.ts"; + +export interface MarkdownViewProps { + content: string; + onOpenLink?: (href: string) => void; +} + +export function MarkdownView({ content, onOpenLink }: MarkdownViewProps): JSX.Element { + return ( +
{createMarkdownElement(content, onOpenLink)}
+ ); +} diff --git a/src/renderer/src/features/files/codeHighlight.ts b/src/renderer/src/features/files/codeHighlight.ts new file mode 100644 index 00000000..aa2ae298 --- /dev/null +++ b/src/renderer/src/features/files/codeHighlight.ts @@ -0,0 +1,41 @@ +import { toJsxRuntime } from "hast-util-to-jsx-runtime"; +import { common, createLowlight } from "lowlight"; +import type { ReactElement } from "react"; +import { Fragment, jsx, jsxs } from "react/jsx-runtime"; +import { COMMON_LANGUAGES } from "./fileLanguage.ts"; + +/** + * Deterministic code highlighting for the file viewer. This module carries no + * JSX so it can be imported directly by node:test; the matching React wrapper + * lives in `CodeView.tsx`. + * + * The lowlight instance registers the curated `common` set (not `all`) because + * `COMMON_LANGUAGES` in `fileLanguage.ts` is constrained to that set. + */ +const lowlight = createLowlight(common); + +// Invariant relied on by detection: every id `detectFileLanguage` can return is +// registered here, so a detected language is never silently unhighlighted. +const missingLanguages = COMMON_LANGUAGES.filter((language) => !lowlight.registered(language)); +if (missingLanguages.length > 0) { + throw new Error(`lowlight is missing registered languages: ${missingLanguages.join(", ")}`); +} + +/** True when `language` names a non-empty language registered by lowlight. */ +export function isRegisteredLanguage(language: string | null | undefined): boolean { + if (typeof language !== "string" || language.length === 0) return false; + return lowlight.registered(language); +} + +/** + * Highlights `content` as `language`, returning a React element, or null when + * the language is missing or unregistered (callers fall back to plain text). + */ +export function highlightCode( + content: string, + language: string | null | undefined +): ReactElement | null { + if (!isRegisteredLanguage(language)) return null; + const tree = lowlight.highlight(language as string, content); + return toJsxRuntime(tree, { Fragment, jsx, jsxs }); +} diff --git a/src/renderer/src/features/files/fileCardRestore.ts b/src/renderer/src/features/files/fileCardRestore.ts new file mode 100644 index 00000000..50a1bc3f --- /dev/null +++ b/src/renderer/src/features/files/fileCardRestore.ts @@ -0,0 +1,34 @@ +import type { FileCard, FileRootDescriptor, FileRootReference } from "../../../../shared/contracts"; + +/** + * Pure restore helpers for persisted Files cards. These map the settings shape to + * the root reference main needs to re-register and decide whether a restored + * root is usable, without touching IPC or the filesystem. + */ + +/** + * Rebuilds the root reference to register on launch. Session cards use their + * session id; folder cards use the persisted absolute `folderPath` (falling back + * to a path embedded in the root reference). Returns null when a card has no + * restorable root — e.g. a folder card whose folder was never chosen. + */ +export function fileCardToRootReference( + card: Pick +): FileRootReference | null { + if (card.root.rootType === "session") { + return card.root.sessionId ? { rootType: "session", sessionId: card.root.sessionId } : null; + } + const folderPath = card.folderPath ?? card.root.folderPath ?? null; + return folderPath ? { rootType: "folder", folderPath } : null; +} + +/** + * A restored root is usable only when registration returned a descriptor that is + * explicitly available. A null (missing/unreadable root) or `available: false` + * descriptor must render the card as unavailable instead of reading anything. + */ +export function isFileRootUsable( + descriptor: FileRootDescriptor | null +): descriptor is FileRootDescriptor { + return descriptor !== null && descriptor.available !== false; +} diff --git a/src/renderer/src/features/files/fileCardRuntime.ts b/src/renderer/src/features/files/fileCardRuntime.ts new file mode 100644 index 00000000..82e56cfa --- /dev/null +++ b/src/renderer/src/features/files/fileCardRuntime.ts @@ -0,0 +1,40 @@ +import type { FileEntry, FileRootDescriptor } from "../../../../shared/contracts"; +import type { FileReadBinding } from "./fileViewer"; + +/** + * Renderer-only, non-persisted state for one Files card: the opaque root handle + * and the directory/read/search data loaded for it. Persisted identity, bounds, + * root reference, active file, and expanded folders live in + * `AppSettings.fileCards`; this state is (re)derived at runtime. + */ +export interface FileCardRuntimeState { + root: FileRootDescriptor | null; + entries: readonly FileEntry[]; + childrenByDirectory: Readonly>; + readResult: FileReadBinding | null; + loading: boolean; + error: string | null; + rootUnavailable: boolean; + quickOpenQuery: string; + quickOpenResults: readonly string[]; +} + +export const EMPTY_FILE_CARD_RUNTIME: FileCardRuntimeState = { + root: null, + entries: [], + childrenByDirectory: {}, + readResult: null, + loading: false, + error: null, + rootUnavailable: false, + quickOpenQuery: "", + quickOpenResults: [] +}; + +/** Runtime state for a card, falling back to the empty state before it loads. */ +export function fileCardRuntimeFor( + state: Readonly>, + id: string +): FileCardRuntimeState { + return state[id] ?? EMPTY_FILE_CARD_RUNTIME; +} diff --git a/src/renderer/src/features/files/fileLanguage.ts b/src/renderer/src/features/files/fileLanguage.ts new file mode 100644 index 00000000..ae90ec73 --- /dev/null +++ b/src/renderer/src/features/files/fileLanguage.ts @@ -0,0 +1,84 @@ +/** + * Pure path -> language classification for the file viewer. This module carries + * no React and performs no I/O. It maps file extensions to highlight.js + * language ids and flags Markdown so the card can pick a rich renderer. + */ + +export const MARKDOWN_EXTENSIONS: readonly string[] = ["md", "markdown", "mdown", "mkd"]; + +const EXTENSION_LANGUAGE_MAP: Readonly> = { + ts: "typescript", + tsx: "typescript", + js: "javascript", + jsx: "javascript", + mjs: "javascript", + cjs: "javascript", + py: "python", + json: "json", + jsonc: "json", + css: "css", + scss: "scss", + html: "xml", + htm: "xml", + xml: "xml", + svg: "xml", + yaml: "yaml", + yml: "yaml", + sh: "bash", + bash: "bash", + zsh: "bash", + go: "go", + rs: "rust", + java: "java", + c: "c", + h: "c", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + hpp: "cpp", + cs: "csharp", + rb: "ruby", + php: "php", + sql: "sql", + toml: "ini", + kt: "kotlin", + swift: "swift", + lua: "lua", + r: "r", + pl: "perl", + diff: "diff", + patch: "diff", + makefile: "makefile" +}; + +function extensionOf(relativePath: string): string | null { + const basename = relativePath.slice(relativePath.lastIndexOf("/") + 1); + const dotIndex = basename.lastIndexOf("."); + if (dotIndex < 0 || dotIndex === basename.length - 1) return null; + return basename.slice(dotIndex + 1).toLowerCase(); +} + +/** True when the lowercased extension is one of the Markdown extensions. */ +export function isMarkdownPath(relativePath: string): boolean { + const extension = extensionOf(relativePath); + return extension !== null && MARKDOWN_EXTENSIONS.includes(extension); +} + +/** + * Maps a path to a highlight.js language id. Returns null for unknown + * extensions and for Markdown extensions (which take the rich Markdown path). + */ +export function detectFileLanguage(relativePath: string): string | null { + const extension = extensionOf(relativePath); + if (extension === null) return null; + if (MARKDOWN_EXTENSIONS.includes(extension)) return null; + return EXTENSION_LANGUAGE_MAP[extension] ?? null; +} + +/** + * Sorted, de-duplicated registration list for lowlight: every language id + * detection can produce, never Markdown. + */ +export const COMMON_LANGUAGES: readonly string[] = Array.from( + new Set(Object.values(EXTENSION_LANGUAGE_MAP)) +).sort(); diff --git a/src/renderer/src/features/files/fileLinks.ts b/src/renderer/src/features/files/fileLinks.ts new file mode 100644 index 00000000..b6a2d513 --- /dev/null +++ b/src/renderer/src/features/files/fileLinks.ts @@ -0,0 +1,31 @@ +/** + * Pure link and image-source classifiers for Markdown rendering. This module + * carries no React and performs no I/O. It decides which links may be opened + * externally and which image sources are safe to render inline (embedded data + * URLs only, never remote or relative sources). + */ + +/** + * True only for absolute `http:`/`https:` URLs. Relative paths, fragment-only + * anchors, `mailto:`, `javascript:`, `data:`, `ftp:` and unparseable values are + * rejected. + */ +export function isOpenableLink(href: string | null | undefined): boolean { + if (typeof href !== "string" || href.length === 0) return false; + let parsed: URL; + try { + parsed = new URL(href); + } catch { + return false; + } + return parsed.protocol === "http:" || parsed.protocol === "https:"; +} + +/** + * True only for embedded `data:image/...` sources, so remote and relative + * images are never rendered with a network request. + */ +export function isEmbeddedImageSource(src: string | null | undefined): boolean { + if (typeof src !== "string" || src.length === 0) return false; + return /^data:image\//i.test(src); +} diff --git a/src/renderer/src/features/files/fileTree.ts b/src/renderer/src/features/files/fileTree.ts new file mode 100644 index 00000000..d37b08c9 --- /dev/null +++ b/src/renderer/src/features/files/fileTree.ts @@ -0,0 +1,146 @@ +import type { FileEntry } from "../../../../shared/contracts"; + +/** + * Pure, renderer-side tree helpers for the Files card. These functions never + * touch the filesystem or IPC; the main process returns ordered `FileEntry` + * lists and the card composes them into a display tree. + */ + +export interface FileTreeNode { + entry: FileEntry; + /** + * Ordered child nodes, or null when this directory's contents have not been + * loaded yet. A loaded but empty directory is an empty array. + */ + children: FileTreeNode[] | null; +} + +export interface FileTreeRow { + entry: FileEntry; + /** Zero-based nesting depth; root entries are depth 0. */ + depth: number; + isDirectory: boolean; + isExpanded: boolean; + /** True once a directory's children have been loaded into the tree. */ + hasLoadedChildren: boolean; +} + +/** Case-insensitive name compare with a stable case-sensitive tiebreak. */ +function compareNames(left: string, right: string): number { + const leftLower = left.toLowerCase(); + const rightLower = right.toLowerCase(); + if (leftLower < rightLower) return -1; + if (leftLower > rightLower) return 1; + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +/** Directories first, then files, each group sorted by name (case-insensitive). */ +export function orderFileEntries(entries: readonly FileEntry[]): FileEntry[] { + return [...entries].sort((left, right) => { + if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1; + return compareNames(left.name, right.name); + }); +} + +/** + * Builds the visible tree from the root listing plus a map of already-loaded + * directory listings keyed by directory relative path. Directories without a + * loaded listing keep `children: null` so the UI can request expansion. + */ +export function buildFileTree( + rootEntries: readonly FileEntry[], + childrenByDirectory: Readonly> = {} +): FileTreeNode[] { + const build = (entries: readonly FileEntry[]): FileTreeNode[] => + orderFileEntries(entries).map((entry) => { + if (entry.kind !== "directory") { + return { entry, children: null } satisfies FileTreeNode; + } + const loaded = childrenByDirectory[entry.relativePath]; + return { + entry, + children: loaded === undefined ? null : build(loaded) + } satisfies FileTreeNode; + }); + return build(rootEntries); +} + +/** Adds a folder path to the expanded set, or removes it when present. */ +export function toggleExpandedFolder( + expandedFolders: readonly string[], + relativePath: string +): string[] { + if (expandedFolders.includes(relativePath)) { + return expandedFolders.filter((path) => path !== relativePath); + } + return [...expandedFolders, relativePath]; +} + +/** + * Depth-first flattening of the tree into the rows a flat list can render. + * Children are only walked for directories present in `expandedFolders` whose + * listing has actually been loaded. + */ +export function flattenFileTree( + tree: readonly FileTreeNode[], + expandedFolders: readonly string[] +): FileTreeRow[] { + const expanded = new Set(expandedFolders); + const rows: FileTreeRow[] = []; + const walk = (nodes: readonly FileTreeNode[], depth: number): void => { + for (const node of nodes) { + const isDirectory = node.entry.kind === "directory"; + const isExpanded = isDirectory && expanded.has(node.entry.relativePath); + rows.push({ + entry: node.entry, + depth, + isDirectory, + isExpanded, + hasLoadedChildren: node.children !== null + }); + if (isDirectory && isExpanded && node.children !== null) { + walk(node.children, depth + 1); + } + } + }; + walk(tree, 0); + return rows; +} + +function basename(relativePath: string): string { + const index = relativePath.lastIndexOf("/"); + return index === -1 ? relativePath : relativePath.slice(index + 1); +} + +/** + * Ranks file paths for quick open by case-insensitive substring match: + * 4 = filename equals the query, 3 = filename starts with it, 2 = filename + * contains it, 1 = only the directory path contains it. Ties shorten first, + * then sort case-insensitively by full path. + */ +export function matchQuickOpen( + relativePaths: readonly string[], + query: string +): string[] { + const needle = query.trim().toLowerCase(); + if (needle === "") return []; + const matches: Array<{ relativePath: string; score: number }> = []; + for (const relativePath of relativePaths) { + if (!relativePath.toLowerCase().includes(needle)) continue; + const name = basename(relativePath).toLowerCase(); + let score = 1; + if (name === needle) score = 4; + else if (name.startsWith(needle)) score = 3; + else if (name.includes(needle)) score = 2; + matches.push({ relativePath, score }); + } + return matches + .sort((left, right) => + right.score - left.score + || left.relativePath.length - right.relativePath.length + || compareNames(left.relativePath, right.relativePath) + ) + .map((match) => match.relativePath); +} diff --git a/src/renderer/src/features/files/fileViewer.ts b/src/renderer/src/features/files/fileViewer.ts new file mode 100644 index 00000000..ead9669a --- /dev/null +++ b/src/renderer/src/features/files/fileViewer.ts @@ -0,0 +1,169 @@ +import type { FileReadResult, FileReadUnsupportedReason } from "../../../../shared/contracts"; +import { detectFileLanguage, isMarkdownPath } from "./fileLanguage.ts"; + +/** + * Pure classification of a `FileReadResult` into a render model. This module + * carries no React and performs no I/O; the card decides how to paint the + * returned model. + */ + +/** Content above this byte size falls back to plain text without rich rendering. */ +export const MAX_HIGHLIGHT_BYTES = 250 * 1024; + +export type FileRenderKind = + | "markdown" + | "code" + | "plain" + | "image" + | "unsupported" + | "too-large"; + +export interface FileTextViewerModel { + kind: "text"; + renderKind: FileRenderKind; + content: string; + truncated: boolean; + size: number; + sizeLabel: string; + language: string | null; + highlightable: boolean; + richDisabled: boolean; +} + +export interface FileImageViewerModel { + kind: "image"; + renderKind: FileRenderKind; + mediaType: string; + dataUrl: string; + size: number; + sizeLabel: string; +} + +export interface FileUnsupportedViewerModel { + kind: "unsupported"; + renderKind: FileRenderKind; + reason: FileReadUnsupportedReason; + reasonLabel: string; +} + +export interface FileTooLargeViewerModel { + kind: "too-large"; + renderKind: FileRenderKind; + reason: "too-large"; + size: number; + sizeLabel: string; +} + +export type FileViewerModel = + | FileTextViewerModel + | FileImageViewerModel + | FileUnsupportedViewerModel + | FileTooLargeViewerModel; + +/** A read result bound to the relative path it was read for. */ +export interface FileReadBinding { + relativePath: string; + result: FileReadResult; +} + +const UNSUPPORTED_REASON_LABELS: Record = { + binary: "Binary file", + "not-permitted": "File type not permitted", + unavailable: "File unavailable" +}; + +/** Human-readable byte size; powers of 1024 with one decimal above 1 KB. */ +export function formatFileSize(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + if (bytes < 1024) return `${Math.round(bytes)} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = bytes / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + const rounded = value >= 100 ? Math.round(value) : Math.round(value * 10) / 10; + return `${rounded} ${units[unitIndex]}`; +} + +/** + * Resolves the render model for the active file. A read belongs only to the + * path it was made for, so a mismatched binding returns null and the card shows + * loading instead of the previous file's content. + */ +export function resolveActiveFileRead( + activeFile: string | null, + readResult: FileReadBinding | null +): FileViewerModel | null { + if (activeFile === null || readResult === null) return null; + if (readResult.relativePath !== activeFile) return null; + return classifyFileRead(readResult.result, readResult.relativePath); +} + +export function classifyFileRead(result: FileReadResult, relativePath = ""): FileViewerModel { + switch (result.kind) { + case "text": { + const sizeLabel = formatFileSize(result.size); + const base = { + kind: "text" as const, + content: result.content, + truncated: result.truncated, + size: result.size, + sizeLabel + }; + if (isMarkdownPath(relativePath)) { + const richDisabled = result.size > MAX_HIGHLIGHT_BYTES; + return { + ...base, + renderKind: richDisabled ? "plain" : "markdown", + language: null, + highlightable: result.size <= MAX_HIGHLIGHT_BYTES, + richDisabled + }; + } + const language = detectFileLanguage(relativePath); + if (language !== null) { + const highlightable = result.size <= MAX_HIGHLIGHT_BYTES; + return { + ...base, + renderKind: highlightable ? "code" : "plain", + language, + highlightable, + richDisabled: result.size > MAX_HIGHLIGHT_BYTES + }; + } + return { + ...base, + renderKind: "plain", + language: null, + highlightable: true, + richDisabled: false + }; + } + case "image": + return { + kind: "image", + renderKind: "image", + mediaType: result.mediaType, + dataUrl: result.dataUrl, + size: result.size, + sizeLabel: formatFileSize(result.size) + }; + case "unsupported": + return { + kind: "unsupported", + renderKind: "unsupported", + reason: result.reason, + reasonLabel: UNSUPPORTED_REASON_LABELS[result.reason] + }; + case "too-large": + return { + kind: "too-large", + renderKind: "too-large", + reason: "too-large", + size: result.size, + sizeLabel: formatFileSize(result.size) + }; + } +} diff --git a/src/renderer/src/features/files/markdownPipeline.ts b/src/renderer/src/features/files/markdownPipeline.ts new file mode 100644 index 00000000..4d790fc0 --- /dev/null +++ b/src/renderer/src/features/files/markdownPipeline.ts @@ -0,0 +1,93 @@ +import { createElement } from "react"; +import type { MouseEvent, ReactElement } from "react"; +import Markdown, { defaultUrlTransform } from "react-markdown"; +import type { Components, UrlTransform } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import type { Options as SanitizeSchema } from "rehype-sanitize"; +import rehypeHighlight from "rehype-highlight"; +import { isEmbeddedImageSource, isOpenableLink } from "./fileLinks.ts"; + +/** + * Sanitization schema for rendered Markdown. Extends the GitHub-style default + * schema only to preserve syntax-highlight class names produced by + * `rehype-highlight` and to let embedded `data:image/...` sources reach the + * image component. Script stripping and dangerous protocols (`javascript:`, + * etc.) are left untouched. + */ +export const markdownSanitizeSchema: SanitizeSchema = { + ...defaultSchema, + attributes: { + ...defaultSchema.attributes, + span: [...(defaultSchema.attributes?.span ?? []), ["className", "hljs", /^hljs-/, /^language-/]], + code: [...(defaultSchema.attributes?.code ?? []), ["className", "hljs", /^hljs-/, /^language-/]] + }, + protocols: { + ...defaultSchema.protocols, + src: [...(defaultSchema.protocols?.src ?? []), "data"] + } +}; + +/** + * Keeps embedded data-image sources so the `img` component can accept them, + * while delegating every other URL to react-markdown's safe default (which + * drops `javascript:` and other dangerous protocols). + */ +const markdownUrlTransform: UrlTransform = (value) => { + if (isEmbeddedImageSource(value)) return value; + return defaultUrlTransform(value); +}; + +/** + * Builds a React element that renders sanitized Markdown. Raw HTML is never + * enabled (no `rehype-raw`); links are routed through `onOpenLink` only when + * they are absolute http(s) URLs, and images render only when they are embedded + * data URLs. + */ +export function createMarkdownElement( + content: string, + onOpenLink?: (href: string) => void +): ReactElement { + const components: Components = { + a({ href, children }) { + if (isOpenableLink(href)) { + const target = href as string; + return createElement( + "a", + { + href: target, + target: "_blank", + rel: "noreferrer noopener", + onClick: (event: MouseEvent) => { + event.preventDefault(); + onOpenLink?.(target); + } + }, + children + ); + } + return createElement("span", { className: "file-browser-card__markdown-inert" }, children); + }, + img({ src, alt }) { + if (isEmbeddedImageSource(src)) { + return createElement("img", { src, alt }); + } + return createElement( + "span", + { className: "file-browser-card__markdown-image-placeholder" }, + alt || "image" + ); + } + }; + + return createElement( + Markdown, + { + remarkPlugins: [remarkGfm], + rehypePlugins: [rehypeHighlight, [rehypeSanitize, markdownSanitizeSchema]], + urlTransform: markdownUrlTransform, + components + }, + content + ); +} diff --git a/src/renderer/src/features/materials/HandoffDialog.tsx b/src/renderer/src/features/materials/HandoffDialog.tsx new file mode 100644 index 00000000..557b4a56 --- /dev/null +++ b/src/renderer/src/features/materials/HandoffDialog.tsx @@ -0,0 +1,366 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import type { + CanvasMaterial, + HandoffBlock, + HandoffDraft, + HandoffPreview, + LocaleId, + MaterialFailure, + MaterialHandoff, + MaterialRemark, + SessionSnapshot +} from "../../../../shared/contracts"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { UiIcon } from "../../components/UiIcon"; +import { t } from "../../lib/i18n"; +import { useModalFocus } from "../../lib/useModalFocus"; +import { PROVIDERS } from "../../lib/providers"; +import { sessionStatusLabel } from "../../lib/sessionStatus"; +import { HANDOFF_NOTE_LIMIT, handoffAttachesImages, handoffBlockFor, handoffDraftKey } from "../../../../shared/materials"; +import { + deliveryKey, + handoffReasonKey, + pasteNoteKey, + remarkNeedsWork, + remarksByMaterial +} from "./materialRemarksModel"; + +interface HandoffDialogProps { + locale: LocaleId; + initialRemarkIds: string[] | null; + sessions: readonly SessionSnapshot[]; + materials: readonly CanvasMaterial[]; + remarks: readonly MaterialRemark[]; + handoffs: readonly MaterialHandoff[]; + lastSessionId: string | null; + onClose(): void; + onSent(handoff: MaterialHandoff): void; + onFocusSession(session: SessionSnapshot): void; +} + +type SendFailure = { reason: HandoffBlock | MaterialFailure }; + +export function HandoffDialog({ + locale, + initialRemarkIds, + sessions, + materials, + remarks, + handoffs, + lastSessionId, + onClose, + onSent, + onFocusSession +}: HandoffDialogProps): React.JSX.Element | null { + const open = initialRemarkIds !== null; + const dialogRef = useRef(null); + useModalFocus(dialogRef, open); + const agents = useMemo(() => sessions.filter((session) => session.provider !== "terminal"), [sessions]); + const [draftId, setDraftId] = useState(() => crypto.randomUUID()); + const [sessionId, setSessionId] = useState(null); + const [selected, setSelected] = useState>(new Set()); + const [editable, setEditable] = useState>(new Set()); + const [note, setNote] = useState(""); + const [resultsFolder, setResultsFolder] = useState(null); + const [preview, setPreview] = useState(null); + const [previewDraft, setPreviewDraft] = useState(null); + const [previewFailure, setPreviewFailure] = useState(null); + const [sending, setSending] = useState(false); + const [sentId, setSentId] = useState(null); + const [sendFailure, setSendFailure] = useState(null); + const previewRequest = useRef(0); + + useEffect(() => { + if (!open) return; + const eligible = agents.filter((session) => handoffBlockFor(session) === null); + const preferred = eligible.find((session) => session.id === lastSessionId) ?? eligible[0] ?? agents[0] ?? null; + setDraftId(crypto.randomUUID()); + setSessionId(preferred?.id ?? null); + setSelected(new Set(initialRemarkIds.filter((id) => { + const remark = remarks.find((candidate) => candidate.id === id); + return remark ? remarkNeedsWork(remark) : false; + }))); + setEditable(new Set()); + setNote(""); + setResultsFolder(null); + setPreview(null); + setPreviewFailure(null); + setSending(false); + setSentId(null); + setSendFailure(null); + }, [open]); + + const selectable = useMemo(() => remarks.filter((remark) => remarkNeedsWork(remark) || selected.has(remark.id)), [remarks, selected]); + const involved = useMemo(() => { + const ids = new Set(); + for (const remark of remarks) { + if (!selected.has(remark.id)) continue; + ids.add(remark.target.materialId); + if (remark.reference) ids.add(remark.reference.materialId); + } + return materials.filter((material) => ids.has(material.id) && material.location !== null); + }, [materials, remarks, selected]); + const draft = useMemo(() => sessionId ? { + id: draftId, + sessionId, + remarkIds: [...selected], + editableMaterialIds: [...editable].filter((id) => involved.some((material) => material.id === id)), + note, + resultsFolder + } : null, [draftId, editable, involved, note, resultsFolder, selected, sessionId]); + + const draftKey = draft ? handoffDraftKey(draft) : null; + + useEffect(() => { + if (sending) return; + if (!open || sentId || !draft || draft.remarkIds.length === 0) { + setPreview(null); + return; + } + const request = ++previewRequest.current; + const timer = window.setTimeout(() => { + void window.canvasTTY.materials.previewHandoff(draft).then((result) => { + if (request !== previewRequest.current) return; + if (result.ok) { + setPreview(result.preview); + setPreviewDraft(draft); + setPreviewFailure(null); + } else { + setPreview(null); + setPreviewFailure({ reason: result.reason }); + } + }, () => request === previewRequest.current && setPreviewFailure({ reason: "unavailable" })); + }, 220); + return () => window.clearTimeout(timer); + }, [draftKey, open, sentId, sending]); + + if (!open) return null; + + const sent = sentId ? handoffs.find((handoff) => handoff.id === sentId) ?? null : null; + const recipient = sessions.find((session) => session.id === (sent?.sessionId ?? sessionId)) ?? null; + const send = async (): Promise => { + if (!draft || sending) return; + setSending(true); + setSendFailure(null); + try { + const result = await window.canvasTTY.materials.sendHandoff(draft); + if (result.ok) { + setSentId(result.handoff.id); + onSent(result.handoff); + } else { + setSendFailure({ reason: result.reason }); + setDraftId(crypto.randomUUID()); + } + } catch { + setSendFailure({ reason: "unavailable" }); + setDraftId(crypto.randomUUID()); + } finally { + setSending(false); + } + }; + const toggle = (set: ReadonlySet, id: string): Set => { + const next = new Set(set); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }; + const byMaterial = [...remarksByMaterial(selectable)].map(([materialId, group]) => [materials.find((material) => material.id === materialId), group] as const); + + return ( +
{ + if (event.target === event.currentTarget && !sending) onClose(); + }}> +
{ + event.stopPropagation(); + if (event.key === "Escape") { + event.preventDefault(); + if (!sending) onClose(); + } + }}> +
+ +

{t(locale, "handoffDialogTitle")}

+ +
+ {sent ? ( + + ) : ( +
+
+

{t(locale, "handoffRecipient")}

+ {agents.length === 0 ? ( +

{t(locale, "handoffNoAgents")}

+ ) : ( +
+ {agents.map((session) => { + const block = handoffBlockFor(session); + return ( + + ); + })} +
+ )} +

{t(locale, "handoffRemarks")}

+ {byMaterial.map(([material, group]) => ( +
+ {material?.name ?? t(locale, "handoffMissingMaterial")} + {group.map((remark) => ( + + ))} +
+ ))} + {involved.length > 0 && ( + <> +

{t(locale, "handoffWorkingFiles")}

+ {involved.map((material) => ( + + ))} + + )} +

{t(locale, "handoffResults")}

+
+ {resultsFolder ? ( + <> + {resultsFolder} + + + ) : ( + + )} +
+

{t(locale, "handoffResultsHint")}

+

{t(locale, "handoffNote")}

+