From 1509108e0446e380d5c96e0a9b27bd1afb333d2c Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 27 May 2026 10:53:05 +0900 Subject: [PATCH] backup current stable ejclaw state --- .claude/settings.json | 2 +- groups/global/CLAUDE.md | 92 + package-lock.json | 3859 +++++++++++++++++++ prompts/claude-paired-room.md | 2 + prompts/claude-platform.md | 22 - prompts/codex-platform.md | 36 - restart.sh | 1 + setup/index.ts | 1 + setup/login.ts | 228 ++ setup/register.ts | 50 + src/agent-error-detection.test.ts | 11 + src/agent-error-detection.ts | 5 +- src/agent-runner-environment.test.ts | 61 + src/agent-runner-environment.ts | 75 +- src/agent-runner-process.ts | 5 + src/agent-runner.ts | 1 + src/auto-paired-mode.ts | 179 + src/channels/discord.ts | 46 +- src/claude-usage.ts | 172 +- src/codex-usage-collector.ts | 144 +- src/config/load-config.ts | 2 +- src/data-state-files.test.ts | 1 + src/data-state-files.ts | 1 + src/db.test.ts | 12 +- src/db.ts | 2 + src/db/messages.ts | 32 + src/db/paired-turn-outputs.ts | 9 +- src/db/runtime-memory-messages.ts | 17 + src/db/work-items.ts | 28 + src/index.ts | 66 +- src/message-agent-executor-lifecycle.ts | 11 + src/message-agent-executor-paired.ts | 5 +- src/message-agent-executor.test.ts | 33 +- src/message-runtime-gating.test.ts | 81 +- src/message-runtime-gating.ts | 125 +- src/message-runtime-queue.ts | 29 +- src/message-runtime.ts | 24 +- src/message-turn-controller.test.ts | 26 + src/message-turn-controller.ts | 6 + src/paired-execution-context-owner.ts | 71 + src/paired-execution-context-reviewer.ts | 61 +- src/paired-execution-context-shared.test.ts | 36 + src/paired-execution-context-shared.ts | 35 +- src/paired-execution-context.test.ts | 144 + src/paired-trivial-turn-detector.test.ts | 183 + src/paired-trivial-turn-detector.ts | 214 + src/paired-verdict.ts | 28 +- src/restart-context.test.ts | 34 + src/restart-context.ts | 14 +- src/router.test.ts | 134 + src/router.ts | 80 +- src/types.ts | 36 +- src/unified-dashboard.test.ts | 90 +- src/unified-dashboard.ts | 94 +- src/usage-exhaustion.test.ts | 336 ++ src/usage-primer.ts | 188 + status.sh | 1 + 57 files changed, 7127 insertions(+), 154 deletions(-) create mode 100644 package-lock.json create mode 100644 restart.sh create mode 100644 setup/login.ts create mode 100644 src/auto-paired-mode.ts create mode 100644 src/paired-trivial-turn-detector.test.ts create mode 100644 src/paired-trivial-turn-detector.ts create mode 100644 src/usage-exhaustion.test.ts create mode 100644 src/usage-primer.ts create mode 100644 status.sh diff --git a/.claude/settings.json b/.claude/settings.json index 72a599f..c0361fb 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,5 @@ { "env": { - "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE": "50" + "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE": "70" } } diff --git a/groups/global/CLAUDE.md b/groups/global/CLAUDE.md index 5882f16..c382926 100644 --- a/groups/global/CLAUDE.md +++ b/groups/global/CLAUDE.md @@ -5,3 +5,95 @@ This file is for mutable memory shared across Claude groups. Use it for durable facts, preferences, and shared context that may change over time. Do not store platform-wide operating rules here. Those now live in `prompts/claude-platform.md`. + +## Stored credentials + +Shared credentials live at `/home/claude/.config/ejclaw/secrets.json` (chmod 600, owner-only). Read with the Read tool when a session in any channel asks to "저장해둔 계정토큰으로 로그인" or otherwise needs a registered token. + +Schema: `credentials..{type, host, token, note, added_at}`. + +Currently stored: +- `git.tkrmagid.kr` — Gitea personal access token. Use via `Authorization: token ` header, or embed in HTTPS URL as `https://:@git.tkrmagid.kr/...`. For `git clone/push`, prefer the URL form or `git -c http.extraHeader="Authorization: token " clone ...`. Do not paste the raw token into chat replies. +- `sudo` — local sudo password for the `claude` user on this host. Use via `echo "$PW" | sudo -S ` (read the password from `credentials.sudo.password` with the Read tool, then pipe). Do not paste the raw password into chat replies. + +To add or rotate a credential, edit `secrets.json` and append a new entry under `credentials`; update this list with the host and intended use. + +## Room mode policy + +All rooms default to `tribunal` (paired) mode. Owner runs the work, reviewer/arbiter (claude-code) verifies. New rooms registered via `bun setup/index.ts --step register` are also `tribunal` by default. + +If the user in a channel says any of these — "클로드 사용하지 말자", "paired 모드 끄자", "리뷰어 끄자", "이 방은 single 로 바꿔줘" — switch that channel back to `single` mode. Use: + +```bash +bun -e "import { initDatabase, setExplicitRoomMode } from './src/db.js'; initDatabase(); setExplicitRoomMode('', 'single');" +``` + +The reverse phrase ("paired 켜자", "리뷰어 다시 켜자") flips it back to `'tribunal'`. Acknowledge the change in chat and confirm the new mode. + +--- + +# CLAUDE.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..cc35659 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3859 @@ +{ + "name": "ejclaw", + "version": "0.2.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ejclaw", + "version": "0.2.3", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", + "cron-parser": "^5.5.0", + "discord.js": "^14.18.0", + "ejclaw-runners-shared": "file:./runners/shared", + "pino": "^9.6.0", + "pino-pretty": "^13.0.0", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "yaml": "^2.8.2", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.13", + "@types/bun": "^1.3.11", + "@types/node": "^22.10.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.0.18", + "better-sqlite3": "^12.8.0", + "husky": "^9.1.7", + "prettier": "^3.8.1", + "typescript": "^5.7.0", + "vite": "^8.0.10", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/bun": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.3.13.tgz", + "integrity": "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.13" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", + "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.5", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.5", + "vitest": "4.1.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.0.tgz", + "integrity": "sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "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/better-sqlite3": { + "version": "12.9.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.9.0.tgz", + "integrity": "sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bun-types": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.3.13.tgz", + "integrity": "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/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/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cron-parser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", + "integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==", + "license": "MIT", + "dependencies": { + "luxon": "^3.7.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/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==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/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": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.47", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", + "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.26.3", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.3.tgz", + "integrity": "sha512-XEKtYn28YFsiJ5l4fLRyikdbo6RD5oFyqfVHQlvXz2104JhH/E8slN28dbky05w3DCrJcNVWvhVvcJCTSl/KIg==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/ejclaw-runners-shared": { + "resolved": "runners/shared", + "link": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/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==", + "license": "MIT", + "dependencies": { + "once": "^1.4.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==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz", + "integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-copy": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz", + "integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "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": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/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", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, + "node_modules/hono": { + "version": "4.12.15", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz", + "integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "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/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jose": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "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==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "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/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "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-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "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-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "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-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "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==", + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.2.tgz", + "integrity": "sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.1.3", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz", + "integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^4.0.0", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^4.0.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^5.0.2" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-pretty/node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.11", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.11.tgz", + "integrity": "sha512-5dDj8+lmvA8XB78SmzGI8NlQoksv7IfutGWeVZxiixHbO+p4LDPT3wuG/D9sM/wrjZZ9I+Siy/e117vbFPxSZg==", + "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.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "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/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "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/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "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/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "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", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "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.1.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_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "runners/shared": { + "name": "ejclaw-runners-shared", + "version": "1.0.0", + "devDependencies": { + "@types/node": "^22.10.7", + "typescript": "^5.7.3" + } + } + } +} diff --git a/prompts/claude-paired-room.md b/prompts/claude-paired-room.md index 96d2cae..183d7bf 100644 --- a/prompts/claude-paired-room.md +++ b/prompts/claude-paired-room.md @@ -31,6 +31,8 @@ If you see a materially better design, debugging path, or scoping choice, propos - **BLOCKED** — Cannot proceed without user decision - **NEEDS_CONTEXT** — Missing information from user +Do not start with non-status review labels like **APPROVE** or **REVISE**. Use **TASK_DONE** for approval and **DONE_WITH_CONCERNS** for change requests. + ## Rules - Judge completion only by verification output. "It should work now" means run it. "I'm confident" means nothing — confidence is not evidence. "I tested earlier" means test again if code changed since. "It's a trivial change" means verify anyway diff --git a/prompts/claude-platform.md b/prompts/claude-platform.md index 42a7abf..4373ff9 100644 --- a/prompts/claude-platform.md +++ b/prompts/claude-platform.md @@ -4,25 +4,3 @@ You have a `send_message` tool that sends a message immediately while you are st Use it to acknowledge a request before starting longer work. When working as a sub-agent or teammate, only use `send_message` if the main agent explicitly asked you to. - -## Image attachments - -When a locally generated image or screenshot should appear in Discord, return an EJClaw structured output with `attachments` instead of only writing the file path in prose: - -```json -{ - "ejclaw": { - "visibility": "public", - "text": "스크린샷을 첨부했습니다.", - "attachments": [ - { - "path": "/absolute/path/screenshot.png", - "name": "screenshot.png", - "mime": "image/png" - } - ] - } -} -``` - -Use absolute local paths only, and do not repeat the same path in the visible text. Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted. diff --git a/prompts/codex-platform.md b/prompts/codex-platform.md index d2e07ca..2c7295f 100644 --- a/prompts/codex-platform.md +++ b/prompts/codex-platform.md @@ -27,39 +27,3 @@ Your output is sent directly to the Discord group. - For CI/status/watch requests that require future follow-up, schedule `watch_ci` - Do not use generic recurring task registration from Codex - If the user wants a reminder or other non-CI recurring task, tell them to ask Claude/클코 to schedule it - -## Image attachments - -When you need to show a locally generated image, screenshot, or other raster output in Discord, do not rely on a raw file path in prose. Emit an EJClaw structured output with `attachments`. - -```json -{ - "ejclaw": { - "visibility": "public", - "text": "이미지를 생성했습니다.", - "verdict": "done", - "attachments": [ - { - "path": "/absolute/path/image.png", - "name": "image.png", - "mime": "image/png" - } - ] - } -} -``` - -- `attachments.path` must be an absolute local path; URLs and relative paths are ignored -- Do not repeat the same file path in the visible text -- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted. -- Use this for generated images and e2e screenshots; the Discord channel validates and uploads the file - -## CI 감시 (watch_ci) - -GitHub Actions run 감시는 structured 필드를 우선 사용: - -- ci_provider: "github", ci_repo: "owner/repo", ci_run_id: run ID -- 이 조합 → host-driven fast path (LLM 토큰 소모 없음, 15초 polling) -- structured 필드 없이 generic 등록 시 매 tick LLM 실행됨 -- ci_pr_number는 아직 미지원 -- GitHub 외 CI는 기존 generic 경로 사용 diff --git a/restart.sh b/restart.sh new file mode 100644 index 0000000..834bc66 --- /dev/null +++ b/restart.sh @@ -0,0 +1 @@ +systemctl --user restart ejclaw diff --git a/setup/index.ts b/setup/index.ts index 91241dd..f0d52a2 100644 --- a/setup/index.ts +++ b/setup/index.ts @@ -17,6 +17,7 @@ const STEPS: Record< 'restart-stack': () => import('./restart-stack.js'), service: () => import('./service.js'), verify: () => import('./verify.js'), + login: () => import('./login.js'), }; async function main(): Promise { diff --git a/setup/login.ts b/setup/login.ts new file mode 100644 index 0000000..96d7a4f --- /dev/null +++ b/setup/login.ts @@ -0,0 +1,228 @@ +/** + * Step: login — Sign in to Claude (Pro/Max) via OAuth PKCE manual flow. + * + * Mirrors the URL Claude Code's `claude auth login --claudeai` actually + * builds (captured from the official CLI). This is the manual / paste-code + * variant: the user visits the authorize URL, completes login in their + * browser, and pastes the code back. We exchange it for tokens and write + * `~/.claude/.credentials.json`. + * + * Usage: + * bun setup/index.ts --step login # phase 1: print authorize URL + * bun setup/index.ts --step login --code # phase 2: exchange the code + * + * Phase 1 stashes the PKCE verifier + state in /tmp/ejclaw-claude-login.json + * (mode 0600). Phase 2 reads it back, exchanges the code, writes credentials. + * + * This step is run by hand for re-auth. Once `.credentials.json` exists, the + * existing token-refresh loop (src/token-refresh.ts) keeps it fresh. + */ + +import crypto from 'crypto'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +import { logger } from '../src/logger.js'; +import { emitStatus } from './status.js'; + +const CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; +const AUTHORIZE_URL = 'https://claude.com/cai/oauth/authorize'; +const TOKEN_URL = 'https://platform.claude.com/v1/oauth/token'; +const REDIRECT_URI = 'https://platform.claude.com/oauth/code/callback'; +const SCOPES = [ + 'org:create_api_key', + 'user:profile', + 'user:inference', + 'user:sessions:claude_code', + 'user:mcp_servers', + 'user:file_upload', +]; + +const STATE_FILE = path.join(os.tmpdir(), 'ejclaw-claude-login.json'); +const CREDS_PATH = path.join(os.homedir(), '.claude', '.credentials.json'); + +interface PendingState { + verifier: string; + state: string; + createdAt: number; +} + +interface ExchangeResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + scope?: string; + account?: { + email_address?: string; + has_claude_max?: boolean; + has_claude_pro?: boolean; + }; +} + +function base64url(buf: Buffer): string { + return buf + .toString('base64') + .replace(/=+$/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} + +function generatePkce(): { verifier: string; challenge: string } { + const verifier = base64url(crypto.randomBytes(32)); + const challenge = base64url( + crypto.createHash('sha256').update(verifier).digest(), + ); + return { verifier, challenge }; +} + +function buildAuthorizeUrl(state: string, challenge: string): string { + const url = new URL(AUTHORIZE_URL); + url.searchParams.append('code', 'true'); + url.searchParams.append('client_id', CLIENT_ID); + url.searchParams.append('response_type', 'code'); + url.searchParams.append('redirect_uri', REDIRECT_URI); + url.searchParams.append('scope', SCOPES.join(' ')); + url.searchParams.append('code_challenge', challenge); + url.searchParams.append('code_challenge_method', 'S256'); + url.searchParams.append('state', state); + return url.toString(); +} + +function writePendingState(p: PendingState): void { + fs.writeFileSync(STATE_FILE, JSON.stringify(p), { mode: 0o600 }); +} + +function readPendingState(): PendingState | null { + if (!fs.existsSync(STATE_FILE)) return null; + try { + return JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) as PendingState; + } catch { + return null; + } +} + +async function exchangeCode( + code: string, + verifier: string, + state: string, +): Promise { + // The success page can hand the user a value of `code#state` or just + // `code` — accept both. + const [bareCode, embeddedState] = code.split('#'); + const stateForExchange = embeddedState || state; + const body = JSON.stringify({ + grant_type: 'authorization_code', + code: bareCode, + redirect_uri: REDIRECT_URI, + client_id: CLIENT_ID, + code_verifier: verifier, + state: stateForExchange, + }); + + const res = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error( + `Token exchange failed (${res.status}): ${text.slice(0, 400)}`, + ); + } + return (await res.json()) as ExchangeResponse; +} + +function writeCredentials(resp: ExchangeResponse): void { + const expiresAt = Date.now() + resp.expires_in * 1000; + const creds = { + claudeAiOauth: { + accessToken: resp.access_token, + refreshToken: resp.refresh_token || '', + expiresAt, + scopes: resp.scope ? resp.scope.split(' ') : SCOPES, + subscriptionType: resp.account?.has_claude_max + ? 'max' + : resp.account?.has_claude_pro + ? 'pro' + : '', + }, + }; + const dir = path.dirname(CREDS_PATH); + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const tmp = `${CREDS_PATH}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 }); + fs.renameSync(tmp, CREDS_PATH); +} + +interface Args { + code: string | null; +} + +function parseArgs(args: string[]): Args { + let code: string | null = null; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--code' && args[i + 1]) { + code = args[++i]; + } + } + return { code }; +} + +export async function run(args: string[]): Promise { + const { code } = parseArgs(args); + + if (!code) { + // Phase 1: produce the authorize URL. + const { verifier, challenge } = generatePkce(); + const state = base64url(crypto.randomBytes(32)); + writePendingState({ verifier, state, createdAt: Date.now() }); + const url = buildAuthorizeUrl(state, challenge); + + console.log('AUTHORIZE_URL=' + url); + console.log( + 'NEXT: open the URL above in your browser, complete the login,' + + ' and re-run this command with --code .', + ); + emitStatus('LOGIN_URL', { STATUS: 'awaiting_code', URL: url }); + return; + } + + // Phase 2: exchange the code. + const pending = readPendingState(); + if (!pending) { + emitStatus('LOGIN', { + STATUS: 'failed', + ERROR: 'no_pending_state — run phase 1 first', + }); + process.exit(4); + } + if (Date.now() - pending.createdAt > 30 * 60 * 1000) { + logger.warn( + 'Pending login state is older than 30 minutes — proceeding anyway', + ); + } + + try { + const resp = await exchangeCode(code, pending.verifier, pending.state); + writeCredentials(resp); + fs.unlinkSync(STATE_FILE); + const newScopes = (resp.scope || SCOPES.join(' ')).split(' ').sort(); + logger.info( + { scopes: newScopes, expiresInMin: Math.round(resp.expires_in / 60) }, + 'Wrote ~/.claude/.credentials.json', + ); + emitStatus('LOGIN', { + STATUS: 'success', + CREDENTIALS_PATH: CREDS_PATH, + SCOPES: newScopes.join(','), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error({ err: message }, 'Token exchange failed'); + emitStatus('LOGIN', { STATUS: 'failed', ERROR: message }); + process.exit(4); + } +} diff --git a/setup/register.ts b/setup/register.ts index 7901411..650abad 100644 --- a/setup/register.ts +++ b/setup/register.ts @@ -3,6 +3,7 @@ * * EJClaw is Discord-only, so registrations must target Discord channel IDs. */ +import { execFileSync } from 'child_process'; import fs from 'fs'; import path from 'path'; @@ -12,6 +13,45 @@ import { isValidGroupFolder } from '../src/group-folder.js'; import { logger } from '../src/logger.js'; import { emitStatus } from './status.js'; +/** + * Resolve a usable git-repo work_dir for a new tribunal room. + * Order: if .git exists, else /repo if .git exists, + * else `git init` (with an empty initial commit) and use it. + * Returns the absolute work_dir path. + */ +function ensureWorkDirForFolder(folderName: string): string { + const folderPath = path.join(GROUPS_DIR, folderName); + fs.mkdirSync(folderPath, { recursive: true }); + + if (fs.existsSync(path.join(folderPath, '.git'))) { + return folderPath; + } + const repoSub = path.join(folderPath, 'repo'); + if (fs.existsSync(path.join(repoSub, '.git'))) { + return repoSub; + } + + // No git repo found anywhere — initialize at the folder root. + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: 'ejclaw', + GIT_AUTHOR_EMAIL: 'ejclaw@local', + GIT_COMMITTER_NAME: 'ejclaw', + GIT_COMMITTER_EMAIL: 'ejclaw@local', + }; + execFileSync('git', ['init', '-q'], { cwd: folderPath, env: gitEnv }); + execFileSync( + 'git', + ['commit', '-q', '--allow-empty', '-m', `init ${folderName} workspace`], + { cwd: folderPath, env: gitEnv }, + ); + logger.info( + { folder: folderName, workDir: folderPath }, + 'Initialized git repository for tribunal workspace', + ); + return folderPath; +} + interface RegisterArgs { jid: string; name: string; @@ -102,10 +142,20 @@ export async function run(args: string[]): Promise { logger.info(parsed, 'Registering channel'); initDatabase(); + + // Defaults for newly registered rooms: + // roomMode = tribunal, owner = claude-code, reviewer = codex + // Tribunal rooms also require a git-backed work_dir; auto-init if missing. + const workDir = ensureWorkDirForFolder(parsed.folder); + assignRoom(parsed.jid, { name: parsed.name, folder: parsed.folder, isMain: parsed.isMain, + roomMode: 'tribunal', + ownerAgentType: 'claude-code', + reviewerAgentType: 'codex', + workDir, }); logger.info('Assigned room through canonical room service'); diff --git a/src/agent-error-detection.test.ts b/src/agent-error-detection.test.ts index 0e33261..14c593d 100644 --- a/src/agent-error-detection.test.ts +++ b/src/agent-error-detection.test.ts @@ -55,6 +55,17 @@ describe('agent-error-detection', () => { expect(detectClaudeProviderFailureMessage(message)).toBe('overloaded'); }); + it('classifies Claude 500 API banners as overloaded provider failures', () => { + const message = + 'API Error: 500 Internal server error. This is a server-side issue, usually temporary — try again in a moment. If it persists, check status.claude.com.'; + + expect(classifyAgentError(message)).toEqual({ + category: 'overloaded', + reason: 'overloaded', + }); + expect(detectClaudeProviderFailureMessage(message)).toBe('overloaded'); + }); + it('marks only Claude quota/auth reasons as Claude rotation reasons', () => { expect(shouldRotateClaudeToken('429')).toBe(true); expect(shouldRotateClaudeToken('usage-exhausted')).toBe(true); diff --git a/src/agent-error-detection.ts b/src/agent-error-detection.ts index 159a100..9daea5e 100644 --- a/src/agent-error-detection.ts +++ b/src/agent-error-detection.ts @@ -260,8 +260,11 @@ export function classifyAgentError( return { category: 'rate-limit', reason: '429', retryAfterMs }; } - // 503 / Overloaded + const hasApi5xx = /\bapi error:\s*5\d\d\b/i.test(error); + + // 5xx / Overloaded if ( + hasApi5xx || lower.includes('503') || lower.includes('overloaded') || ((lower.includes('502') || lower.includes('bad gateway')) && diff --git a/src/agent-runner-environment.test.ts b/src/agent-runner-environment.test.ts index 74a454a..aa08711 100644 --- a/src/agent-runner-environment.test.ts +++ b/src/agent-runner-environment.test.ts @@ -368,6 +368,67 @@ describe('prepareGroupEnvironment codex auth handling', () => { expect(segments).toEqual(['platform prompt']); }); + + it('applies per-role codex model overrides when roomRole is supplied', () => { + mockReadEnvFile.mockReturnValue({}); + + const groupWithByRole: RegisteredGroup = { + ...group, + agentType: 'codex', + agentConfig: { + codexModel: 'gpt-5-sonnet', + codexEffort: 'medium', + codexModelByRole: { arbiter: 'gpt-5-opus' }, + codexEffortByRole: { arbiter: 'high' }, + }, + }; + + const arbiter = prepareGroupEnvironment(groupWithByRole, false, 'dc:test', { + roomRole: 'arbiter', + }); + expect(arbiter.env.CODEX_MODEL).toBe('gpt-5-opus'); + expect(arbiter.env.CODEX_EFFORT).toBe('high'); + + const owner = prepareGroupEnvironment(groupWithByRole, false, 'dc:test', { + roomRole: 'owner', + }); + expect(owner.env.CODEX_MODEL).toBe('gpt-5-sonnet'); + expect(owner.env.CODEX_EFFORT).toBe('medium'); + + const noRole = prepareGroupEnvironment(groupWithByRole, false, 'dc:test'); + expect(noRole.env.CODEX_MODEL).toBe('gpt-5-sonnet'); + expect(noRole.env.CODEX_EFFORT).toBe('medium'); + }); + + it('applies per-role claude model overrides when roomRole is supplied', () => { + mockReadEnvFile.mockReturnValue({}); + + const groupWithByRole: RegisteredGroup = { + ...group, + agentType: 'claude-code', + agentConfig: { + claudeModel: 'claude-sonnet-4-6', + claudeEffort: 'medium', + claudeModelByRole: { arbiter: 'claude-opus-4-7' }, + claudeEffortByRole: { arbiter: 'high' }, + }, + }; + + const arbiter = prepareGroupEnvironment(groupWithByRole, false, 'dc:test', { + roomRole: 'arbiter', + }); + expect(arbiter.env.CLAUDE_MODEL).toBe('claude-opus-4-7'); + expect(arbiter.env.CLAUDE_EFFORT).toBe('high'); + + const reviewer = prepareGroupEnvironment( + groupWithByRole, + false, + 'dc:test', + { roomRole: 'reviewer' }, + ); + expect(reviewer.env.CLAUDE_MODEL).toBe('claude-sonnet-4-6'); + expect(reviewer.env.CLAUDE_EFFORT).toBe('medium'); + }); }); describe('prepareReadonlySessionEnvironment codex compatibility', () => { diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index f8b66c6..e077908 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -32,7 +32,7 @@ import { getEffectiveChannelLease, hasReviewerLease, } from './service-routing.js'; -import type { AgentType, RegisteredGroup } from './types.js'; +import type { AgentType, PairedRoomRole, RegisteredGroup } from './types.js'; // writeCodexApiKeyAuth removed — Codex uses OAuth only. // API key auth caused unintended billing. @@ -232,6 +232,7 @@ function prepareClaudeEnvironment(args: { env: Record; envVars: Record; group: RegisteredGroup; + roomRole?: PairedRoomRole; }): void { if (args.envVars.ANTHROPIC_API_KEY || process.env.ANTHROPIC_API_KEY) { args.env.ANTHROPIC_API_KEY = @@ -279,6 +280,15 @@ function prepareClaudeEnvironment(args: { if (args.group.agentConfig?.claudeEffort) { args.env.CLAUDE_EFFORT = args.group.agentConfig.claudeEffort; } + // Per-role overrides (paired room): take precedence over the flat values. + if (args.roomRole) { + const modelByRole = + args.group.agentConfig?.claudeModelByRole?.[args.roomRole]; + if (modelByRole) args.env.CLAUDE_MODEL = modelByRole; + const effortByRole = + args.group.agentConfig?.claudeEffortByRole?.[args.roomRole]; + if (effortByRole) args.env.CLAUDE_EFFORT = effortByRole; + } if (args.group.agentConfig?.claudeThinking) { args.env.CLAUDE_THINKING = args.group.agentConfig.claudeThinking; } @@ -301,19 +311,29 @@ function prepareCodexSessionEnvironment(args: { isPairedRoom: boolean; useFailoverPromptPack: boolean; memoryBriefing?: string; + roomRole?: PairedRoomRole; }): void { // API key auth intentionally removed — Codex uses OAuth only. // Never pass any API key to Codex child process to prevent API billing. delete args.env.OPENAI_API_KEY; delete args.env.CODEX_OPENAI_API_KEY; + // Per-role overrides (paired room) win over flat config which wins over env. + const codexModelByRole = args.roomRole + ? args.group.agentConfig?.codexModelByRole?.[args.roomRole] + : undefined; const codexModel = + codexModelByRole || args.group.agentConfig?.codexModel || args.envVars.CODEX_MODEL || process.env.CODEX_MODEL; if (codexModel) args.env.CODEX_MODEL = codexModel; + const codexEffortByRole = args.roomRole + ? args.group.agentConfig?.codexEffortByRole?.[args.roomRole] + : undefined; const codexEffort = + codexEffortByRole || args.group.agentConfig?.codexEffort || args.envVars.CODEX_EFFORT || process.env.CODEX_EFFORT; @@ -338,6 +358,23 @@ function prepareCodexSessionEnvironment(args: { } const sessionAgentsPath = path.join(sessionCodexDir, 'AGENTS.md'); + const codexGlobalDir = path.join(GROUPS_DIR, 'global'); + const codexGlobalClaudeMdPath = path.join(codexGlobalDir, 'CLAUDE.md'); + const codexGlobalMemory = + !args.isMain && fs.existsSync(codexGlobalClaudeMdPath) + ? fs.readFileSync(codexGlobalClaudeMdPath, 'utf-8').trim() + : undefined; + const codexGroupClaudeMdPath = path.join( + GROUPS_DIR, + args.group.folder, + 'CLAUDE.md', + ); + const codexGroupMemory = + args.group.folder !== 'global' && + args.group.folder !== 'main' && + fs.existsSync(codexGroupClaudeMdPath) + ? fs.readFileSync(codexGroupClaudeMdPath, 'utf-8').trim() + : undefined; const sessionAgents = ( args.useFailoverPromptPack ? [ @@ -352,9 +389,12 @@ function prepareCodexSessionEnvironment(args: { 'owner-common-paired-room.md', ) : undefined, + codexGlobalMemory, + codexGroupMemory, args.memoryBriefing, ] : [ + readOptionalPromptFile(args.projectRoot, 'owner-common-platform.md'), readPlatformPrompt('codex', args.projectRoot), args.isPairedRoom ? readOptionalPromptFile( @@ -362,6 +402,8 @@ function prepareCodexSessionEnvironment(args: { 'owner-common-paired-room.md', ) : undefined, + codexGlobalMemory, + codexGroupMemory, args.memoryBriefing, ] ) @@ -429,6 +471,13 @@ export function prepareGroupEnvironment( memoryBriefing?: string; runtimeTaskId?: string; useTaskScopedSession?: boolean; + /** + * When provided, the runner consults `agentConfig.{claudeModel,claudeEffort, + * codexModel,codexEffort}ByRole[roomRole]` and lets those entries override + * the flat per-group values. Used by paired rooms to spend the expensive + * model on a specific role (typically arbiter) only. + */ + roomRole?: PairedRoomRole; }, ): PreparedGroupEnvironment { const projectRoot = process.cwd(); @@ -494,6 +543,13 @@ export function prepareGroupEnvironment( !isMain && fs.existsSync(globalClaudeMdPath) ? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim() : undefined; + const groupClaudeMdPath = path.join(GROUPS_DIR, group.folder, 'CLAUDE.md'); + const groupClaudeMemory = + group.folder !== 'global' && + group.folder !== 'main' && + fs.existsSync(groupClaudeMdPath) + ? fs.readFileSync(groupClaudeMdPath, 'utf-8').trim() + : undefined; // Owner CLAUDE.md: platform rules + owner paired room rules. // Reviewer paired room rules are NOT included — those belong to the // Read-only reviewer/arbiter session only (via prepareReadonlySessionEnvironment). @@ -502,6 +558,7 @@ export function prepareGroupEnvironment( claudePlatformPrompt, ownerCommonPairedRoomPrompt, globalClaudeMemory, + groupClaudeMemory, options?.memoryBriefing, ] .filter((value): value is string => Boolean(value)) @@ -559,9 +616,15 @@ export function prepareGroupEnvironment( isPairedRoom, useFailoverPromptPack: useCodexReviewFailoverPromptPack, memoryBriefing: options?.memoryBriefing, + roomRole: options?.roomRole, }); } else { - prepareClaudeEnvironment({ env, envVars, group }); + prepareClaudeEnvironment({ + env, + envVars, + group, + roomRole: options?.roomRole, + }); } return { env, groupDir, runnerDir }; @@ -626,11 +689,19 @@ export function prepareReadonlySessionEnvironment(args: { !isMain && fs.existsSync(globalClaudeMdPath) ? fs.readFileSync(globalClaudeMdPath, 'utf-8').trim() : undefined; + const groupClaudeMdPath = path.join(GROUPS_DIR, groupFolder, 'CLAUDE.md'); + const groupClaudeMemory = + groupFolder !== 'global' && + groupFolder !== 'main' && + fs.existsSync(groupClaudeMdPath) + ? fs.readFileSync(groupClaudeMdPath, 'utf-8').trim() + : undefined; const sessionClaudeMd = [ claudePlatformPrompt, claudePairedRoomPrompt, globalClaudeMemory, + groupClaudeMemory, memoryBriefing, ] .filter((value): value is string => Boolean(value)) diff --git a/src/agent-runner-process.ts b/src/agent-runner-process.ts index 37f7801..433523c 100644 --- a/src/agent-runner-process.ts +++ b/src/agent-runner-process.ts @@ -61,6 +61,11 @@ export function runSpawnedAgentProcess( let stdoutTruncated = false; let stderrTruncated = false; + // Use UTF-8 string decoding on streams to avoid splitting multi-byte + // characters (e.g. Korean) at chunk boundaries, which produces U+FFFD. + stdoutStream.setEncoding('utf8'); + stderrStream.setEncoding('utf8'); + // Streaming output: parse OUTPUT_START/END marker pairs as they arrive. let parseBuffer = ''; let newSessionId: string | undefined; diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 0f4fb12..0e96f97 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -73,6 +73,7 @@ export async function runAgentProcess( memoryBriefing: input.memoryBriefing, runtimeTaskId: input.runtimeTaskId, useTaskScopedSession: input.useTaskScopedSession, + roomRole: input.roomRoleContext?.role, }, ); diff --git a/src/auto-paired-mode.ts b/src/auto-paired-mode.ts new file mode 100644 index 0000000..5ecada4 --- /dev/null +++ b/src/auto-paired-mode.ts @@ -0,0 +1,179 @@ +/** + * Auto paired-mode degrade/restore. + * + * When all configured Claude accounts are at/over the 95% exhaustion threshold, + * paired (tribunal) rooms cannot meaningfully run their reviewer/arbiter + * because the gate blocks Claude turns. To keep the user-facing rooms + * responsive, we temporarily flip selected rooms to `single` mode and notify + * each affected channel. When usage recovers we flip them back to `tribunal`. + * + * Selection (Option B agreed with the user): + * - The most-recently-active room (per chats.last_message_time) + * - All rooms that are currently in-flight (queue status === 'processing') + * - Deduplicated; rooms whose owner is `claude-code` are skipped because + * degrading them does not help — the 95% gate blocks them either way. + * + * Restore behavior: + * - Only restore a room if it is still in 'single' (don't override a manual + * user change made during the degrade window). + */ +import fs from 'fs'; +import path from 'path'; + +import { getClaudeUsageExhaustion } from './claude-usage.js'; +import { DATA_DIR } from './config.js'; +import { + getAllChats, + getAllRoomBindings, + getEffectiveRoomMode, + getStoredRoomSettings, + setExplicitRoomMode, +} from './db.js'; +import { logger } from './logger.js'; +import { readJsonFile, writeJsonFile } from './utils.js'; + +const STATE_PATH = path.join(DATA_DIR, 'auto-paired-mode-state.json'); + +const DEGRADE_NOTICE = + '클로드 사용량이 95%를 넘어 잠시 single 모드로 전환했어요. 사용량이 회복되면 paired 모드로 다시 켤게요.'; +const RESTORE_NOTICE = '클로드 사용량이 회복돼서 paired 모드로 다시 켰어요.'; + +interface DowngradeRecord { + chatJid: string; + atIso: string; +} + +interface AutoPairedModeState { + exhausted: boolean; + downgraded: DowngradeRecord[]; +} + +export interface AutoPairedModeDeps { + queue?: { + getStatuses(jids: string[]): Array<{ jid: string; status: string }>; + }; + sendNotice: (chatJid: string, text: string) => Promise; +} + +function loadState(): AutoPairedModeState { + const raw = readJsonFile(STATE_PATH); + if (!raw || typeof raw !== 'object') { + return { exhausted: false, downgraded: [] }; + } + return { + exhausted: Boolean(raw.exhausted), + downgraded: Array.isArray(raw.downgraded) ? raw.downgraded : [], + }; +} + +function saveState(state: AutoPairedModeState): void { + try { + fs.mkdirSync(path.dirname(STATE_PATH), { recursive: true }); + writeJsonFile(STATE_PATH, state); + } catch (err) { + logger.warn({ err }, 'auto-paired-mode: failed to persist state'); + } +} + +function pickCandidates(deps: AutoPairedModeDeps): string[] { + const bindings = getAllRoomBindings(); + const allJids = Object.keys(bindings); + if (allJids.length === 0) return []; + + const candidates = new Set(); + + // Most-recently-active room first (chats ordered DESC by last_message_time). + const chats = getAllChats(); + const recent = chats.find((c) => allJids.includes(c.jid)); + if (recent) candidates.add(recent.jid); + + // Currently in-flight rooms. + if (deps.queue) { + for (const status of deps.queue.getStatuses(allJids)) { + if (status.status === 'processing') candidates.add(status.jid); + } + } + + // Skip claude-owned rooms (Option B). + const result: string[] = []; + for (const jid of candidates) { + const settings = getStoredRoomSettings(jid); + if (settings?.ownerAgentType === 'claude-code') continue; + if (getEffectiveRoomMode(jid) !== 'tribunal') continue; + result.push(jid); + } + return result; +} + +export async function evaluateAndApplyAutoPairedMode( + deps: AutoPairedModeDeps, +): Promise { + const exhaustion = getClaudeUsageExhaustion(); + const state = loadState(); + + if (exhaustion.exhausted && !state.exhausted) { + // Transition: healthy → exhausted. Downgrade selected rooms. + const targets = pickCandidates(deps); + const downgraded: DowngradeRecord[] = []; + for (const jid of targets) { + try { + setExplicitRoomMode(jid, 'single'); + downgraded.push({ chatJid: jid, atIso: new Date().toISOString() }); + logger.info( + { jid }, + 'auto-paired-mode: downgraded room to single (claude exhausted)', + ); + try { + await deps.sendNotice(jid, DEGRADE_NOTICE); + } catch (err) { + logger.warn( + { err, jid }, + 'auto-paired-mode: failed to send degrade notice', + ); + } + } catch (err) { + logger.warn({ err, jid }, 'auto-paired-mode: failed to downgrade room'); + } + } + saveState({ exhausted: true, downgraded }); + return; + } + + if (!exhaustion.exhausted && state.exhausted) { + // Transition: exhausted → healthy. Restore previously-downgraded rooms. + for (const record of state.downgraded) { + try { + if (getEffectiveRoomMode(record.chatJid) !== 'single') { + // Respect manual user changes during the degrade window. + logger.info( + { jid: record.chatJid }, + 'auto-paired-mode: skip restore (room is no longer single)', + ); + continue; + } + setExplicitRoomMode(record.chatJid, 'tribunal'); + logger.info( + { jid: record.chatJid }, + 'auto-paired-mode: restored room to tribunal', + ); + try { + await deps.sendNotice(record.chatJid, RESTORE_NOTICE); + } catch (err) { + logger.warn( + { err, jid: record.chatJid }, + 'auto-paired-mode: failed to send restore notice', + ); + } + } catch (err) { + logger.warn( + { err, jid: record.chatJid }, + 'auto-paired-mode: failed to restore room', + ); + } + } + saveState({ exhausted: false, downgraded: [] }); + return; + } + + // Steady state — nothing to do. +} diff --git a/src/channels/discord.ts b/src/channels/discord.ts index 84a64f9..9c66c7d 100644 --- a/src/channels/discord.ts +++ b/src/channels/discord.ts @@ -23,7 +23,11 @@ import { extractImageTagPaths } from '../agent-protocol.js'; import { validateOutboundAttachments } from '../outbound-attachments.js'; import { formatOutbound } from '../router.js'; import { hasReviewerLease } from '../service-routing.js'; -import type { OutboundAttachment, SendMessageOptions } from '../types.js'; +import type { + EditMessageOptions, + OutboundAttachment, + SendMessageOptions, +} from '../types.js'; const ATTACHMENTS_DIR = path.join(DATA_DIR, 'attachments'); const TRANSCRIPTION_CACHE_DIR = path.join(CACHE_DIR, 'transcriptions'); @@ -514,7 +518,7 @@ export class DiscordChannel implements Channel { for (const [name, id] of Object.entries(mentionMap)) { cleaned = cleaned.replace(new RegExp(`@${name}`, 'g'), `<@${id}>`); } - cleaned = formatOutbound(cleaned); + cleaned = options.rawMarkdown ? cleaned : formatOutbound(cleaned); // Discord has a 2000 character limit per message and 10 attachments per message const MAX_LENGTH = 2000; @@ -561,9 +565,21 @@ export class DiscordChannel implements Channel { } } else { // Send text in chunks, attach first batch to the first chunk + // Use a helper that avoids splitting surrogate pairs (emoji, etc.) let fileBatchIndex = 0; - for (let i = 0; i < cleaned.length; i += MAX_LENGTH) { - const chunk = cleaned.slice(i, i + MAX_LENGTH); + for (let i = 0; i < cleaned.length; ) { + let end = Math.min(i + MAX_LENGTH, cleaned.length); + // If we'd split a surrogate pair, back up one code unit + if ( + end < cleaned.length && + end > i && + cleaned.charCodeAt(end - 1) >= 0xd800 && + cleaned.charCodeAt(end - 1) <= 0xdbff + ) { + end--; + } + const chunk = cleaned.slice(i, end); + i = end; const batch = fileBatches[fileBatchIndex]; recordSentMessage( await textChannel.send({ @@ -678,13 +694,18 @@ export class DiscordChannel implements Channel { ); } - async sendAndTrack(jid: string, text: string): Promise { + async sendAndTrack( + jid: string, + text: string, + options: EditMessageOptions = {}, + ): Promise { if (!this.client) return null; try { const channelId = jid.replace(/^dc:/, ''); const channel = await this.client.channels.fetch(channelId); if (!channel || !('send' in channel)) return null; - const msg = await (channel as TextChannel).send(text); + const cleaned = options.rawMarkdown ? text : formatOutbound(text); + const msg = await (channel as TextChannel).send(cleaned); logger.info( { jid, @@ -693,7 +714,7 @@ export class DiscordChannel implements Channel { messageId: msg.id, botUserId: this.client.user?.id ?? null, botUsername: this.client.user?.username ?? null, - length: text.length, + length: cleaned.length, }, 'Discord tracked message sent', ); @@ -814,6 +835,7 @@ export class DiscordChannel implements Channel { jid: string, messageId: string, text: string, + options: EditMessageOptions = {}, ): Promise { if (!this.client) return; try { @@ -821,14 +843,20 @@ export class DiscordChannel implements Channel { const channel = await this.client.channels.fetch(channelId); if (!channel || !('messages' in channel)) return; const msg = await (channel as TextChannel).messages.fetch(messageId); - await msg.edit(text); + // Run the same sanitization pipeline as sendMessage so streaming + // edits do not bypass markdown neutralization (backticks, headings, + // italics, etc. that Discord would otherwise render and mangle). + // Dashboard callers opt out with rawMarkdown: true to preserve + // intentional bold/italic section headers. + const cleaned = options.rawMarkdown ? text : formatOutbound(text); + await msg.edit(cleaned); logger.info( { jid, channelName: this.name, deliveryMode: 'edit', messageId, - length: text.length, + length: cleaned.length, botUserId: this.client.user?.id ?? null, botUsername: this.client.user?.username ?? null, }, diff --git a/src/claude-usage.ts b/src/claude-usage.ts index 3d08ec3..9ff2815 100644 --- a/src/claude-usage.ts +++ b/src/claude-usage.ts @@ -112,12 +112,15 @@ export function getUsageCacheReadKeys( return keys; } -// Rate limit: at most one API call per token per 5 minutes -const MIN_FETCH_INTERVAL_MS = 300_000; +// Rate limit: at most one API call per token per 1 minute. +// We poll usage every minute (was 5) so the 95% exhaustion gate has tight +// resolution — at worst the gate fires ~60s after a window crosses 95%. +const MIN_FETCH_INTERVAL_MS = 60_000; async function fetchUsageForToken( token: string, accountIndex?: number, + refreshAttempted = false, ): Promise { loadUsageDiskCache(); @@ -148,7 +151,11 @@ async function fetchUsageForToken( saveUsageDiskCache(); } const lastAttempt = cached?.lastAttemptAt ?? cached?.fetchedAt ?? 0; - if (cached && Date.now() - lastAttempt < MIN_FETCH_INTERVAL_MS) { + if ( + !refreshAttempted && + cached && + Date.now() - lastAttempt < MIN_FETCH_INTERVAL_MS + ) { return cached.usage; } const controller = new AbortController(); @@ -165,16 +172,47 @@ async function fetchUsageForToken( signal: controller.signal, }); - if (res.status === 401) { + if (res.status === 401 || res.status === 403) { + // 401 = token expired; 403 = token lacks `user:profile` scope. + // Both are recoverable by exchanging refresh_token for a fresh access + // token (mint includes current default scope set). Retry once. + if (accountIndex != null && !refreshAttempted) { + try { + const { forceRefreshToken } = await import('./token-refresh.js'); + const newToken = await forceRefreshToken(accountIndex); + if (newToken && newToken !== token) { + logger.info( + { + account: accountIndex + 1, + status: res.status, + cacheKey: writeKey, + }, + `Claude usage API: ${res.status} → refreshed access token, retrying`, + ); + return fetchUsageForToken(newToken, accountIndex, true); + } + } catch (err) { + logger.warn( + { err, account: accountIndex + 1 }, + 'Claude usage API: force-refresh failed', + ); + } + } logger.warn( { account: accountIndex != null ? accountIndex + 1 : '?', tokenKey: legacyTokenCacheKey(token), cacheKey: writeKey, }, - 'Claude usage API: token expired or invalid (401)', + res.status === 401 + ? 'Claude usage API: token expired or invalid (401)' + : 'Claude usage API: forbidden — token missing required scope (403)', ); - return null; + if (cached) { + cached.lastAttemptAt = Date.now(); + saveUsageDiskCache(); + } + return cached?.usage ?? null; } if (res.status === 429) { const staleMs = cached ? Date.now() - cached.fetchedAt : 0; @@ -278,7 +316,15 @@ async function fetchUsageForToken( * Uses the current active token from rotation. */ export async function fetchClaudeUsage(): Promise { - const token = getCurrentToken() || getConfiguredClaudeTokens()[0]; + // Prefer the access token in ~/.claude/.credentials.json when present. + // The static .env token (CLAUDE_CODE_OAUTH_TOKEN) was issued before the + // `user:profile` scope was required for /api/oauth/usage and so always 403s. + // The credentials file is the canonical source written by `claude auth + // login` and kept fresh by token-refresh.ts — its accessToken carries the + // full scope set including user:profile. + const credsToken = readCredentialsAccessToken(0); + const token = + credsToken || getCurrentToken() || getConfiguredClaudeTokens()[0]; if (!token) { logger.debug('No Claude OAuth token available for usage check'); return null; @@ -435,7 +481,8 @@ export async function fetchAllClaudeUsage(): Promise { const allTokens = getAllTokens(); logger.debug({ tokenCount: allTokens.length }, 'fetchAllClaudeUsage called'); if (allTokens.length === 0) { - const token = getConfiguredClaudeTokens()[0]; + const credsToken = readCredentialsAccessToken(0); + const token = credsToken || getConfiguredClaudeTokens()[0]; if (!token) return []; const usage = await fetchUsageForToken(token, 0); return [ @@ -451,7 +498,14 @@ export async function fetchAllClaudeUsage(): Promise { const results: ClaudeAccountUsage[] = []; for (const t of allTokens) { - const usage = await fetchUsageForToken(t.token, t.index); + // Prefer the per-account credentials.json access token when present. + // The static .env CLAUDE_CODE_OAUTH_TOKEN(S) values were issued without + // the `user:profile` scope and so are rejected by /api/oauth/usage. + // The credentials file is rewritten by `claude auth login` and refreshed + // by token-refresh.ts, so it always carries the full scope set. + const credsToken = readCredentialsAccessToken(t.index); + const tokenForFetch = credsToken || t.token; + const usage = await fetchUsageForToken(tokenForFetch, t.index); results.push({ index: t.index, masked: t.masked, @@ -465,3 +519,103 @@ export async function fetchAllClaudeUsage(): Promise { // Legacy alias export const fetchClaudeUsageViaCli = fetchClaudeUsage; + +// ── Exhaustion gate ─────────────────────────────────────────────────────── +// +// Threshold above which a Claude account is considered "out of budget" for +// the purpose of admitting new turns. A turn is blocked only when *every* +// Claude account is either at/over this threshold on either window or +// already in a rate-limit cooldown. + +export const CLAUDE_EXHAUSTION_THRESHOLD_PCT = 95; + +export interface UsageExhaustionInfo { + exhausted: boolean; + /** ISO timestamp of the soonest reset that would relieve exhaustion. */ + nextResetAt: string | null; +} + +/** Normalise the API's utilization field (which can be fractional or percent). */ +function normaliseUtilization(u: number | undefined): number { + if (u == null || !Number.isFinite(u)) return 0; + return u >= 0 && u < 1 ? u * 100 : u; +} + +function parseResetMs(s: string | undefined | null): number | null { + if (!s) return null; + const t = Date.parse(s); + return Number.isFinite(t) ? t : null; +} + +/** + * Reports whether every configured Claude account is either ≥ 95% on a + * window or in a rate-limit cooldown, and — when so — the soonest moment at + * which any one account is expected to recover. + * + * Reads the on-disk usage cache (refreshed every minute by the dashboard + * renderer); performs no network I/O. Returns `exhausted: false` when there + * are no configured tokens, or when usage data is missing for any account + * (err on letting work through). + */ +export function getClaudeUsageExhaustion(): UsageExhaustionInfo { + loadUsageDiskCache(); + const allTokens = getAllTokens(); + if (allTokens.length === 0) return { exhausted: false, nextResetAt: null }; + + let earliestRecoveryMs = Infinity; + + for (const t of allTokens) { + if (t.isRateLimited) { + // Rate-limit recovery time is not exposed on the token snapshot here; + // we treat it as "unknown but eventually". Skip from the min calc. + continue; + } + const credsToken = readCredentialsAccessToken(t.index); + const readKeys = getUsageCacheReadKeys(t.token, t.index, credsToken); + let entry: UsageCacheEntry | undefined; + for (const key of readKeys) { + if (usageDiskCache[key]) { + entry = usageDiskCache[key]; + break; + } + } + if (!entry) return { exhausted: false, nextResetAt: null }; + + const h5 = normaliseUtilization(entry.usage.five_hour?.utilization); + const d7 = normaliseUtilization(entry.usage.seven_day?.utilization); + if (Math.max(h5, d7) < CLAUDE_EXHAUSTION_THRESHOLD_PCT) { + return { exhausted: false, nextResetAt: null }; + } + + // This account is exhausted. It becomes available again when whichever + // window(s) are over the threshold drop back below it. We approximate + // that as max(reset of each window currently ≥ threshold). + const h5ResetMs = + h5 >= CLAUDE_EXHAUSTION_THRESHOLD_PCT + ? parseResetMs(entry.usage.five_hour?.resets_at) + : null; + const d7ResetMs = + d7 >= CLAUDE_EXHAUSTION_THRESHOLD_PCT + ? parseResetMs(entry.usage.seven_day?.resets_at) + : null; + const candidates = [h5ResetMs, d7ResetMs].filter( + (v): v is number => v != null, + ); + if (candidates.length > 0) { + const accountRecovery = Math.max(...candidates); + if (accountRecovery < earliestRecoveryMs) { + earliestRecoveryMs = accountRecovery; + } + } + } + + const nextResetAt = Number.isFinite(earliestRecoveryMs) + ? new Date(earliestRecoveryMs).toISOString() + : null; + return { exhausted: true, nextResetAt }; +} + +/** Backwards-compat boolean wrapper. */ +export function isClaudeUsageExhausted(): boolean { + return getClaudeUsageExhaustion().exhausted; +} diff --git a/src/codex-usage-collector.ts b/src/codex-usage-collector.ts index 2a59ecb..e824193 100644 --- a/src/codex-usage-collector.ts +++ b/src/codex-usage-collector.ts @@ -31,10 +31,120 @@ export interface CodexUsageRefreshResult { /** Full scan interval — exported so the orchestrator can schedule it. */ export const CODEX_FULL_SCAN_INTERVAL = 3_600_000; // 1 hour +/** + * Threshold above which a Codex account is considered "out of budget" for + * the purpose of admitting new turns. A turn is blocked only when *every* + * Codex account is either at/over this threshold on either window or + * already in a rate-limit cooldown. + */ +export const CODEX_EXHAUSTION_THRESHOLD_PCT = 95; + +export interface UsageExhaustionInfo { + exhausted: boolean; + /** ISO timestamp of the soonest reset that would relieve exhaustion. */ + nextResetAt: string | null; +} + +function parseResetMs(s: string | undefined | null): number | null { + if (!s) return null; + const t = Date.parse(s); + return Number.isFinite(t) ? t : null; +} + +/** Coerce a string-or-number resetsAt (numeric epoch in seconds) to ISO. */ +function toIsoMaybe(value: string | number | undefined): string | undefined { + if (value == null) return undefined; + if (typeof value === 'number') { + return new Date(value * 1000).toISOString(); + } + // Already a string — pass through if it parses, else drop. + const ms = Date.parse(value); + return Number.isFinite(ms) ? new Date(ms).toISOString() : undefined; +} + +/** + * Reports whether every configured Codex account is either ≥ 95% on a + * window or in a rate-limit cooldown, and — when so — the soonest moment + * at which any one account is expected to recover. + * + * Reads the in-process rotation snapshot; performs no I/O. Returns + * `exhausted: false` when there are no configured accounts, or when usage + * data is missing for any account (err on letting work through). + */ +export function getCodexUsageExhaustion(): UsageExhaustionInfo { + const accounts = getAllCodexAccounts(); + if (accounts.length === 0) return { exhausted: false, nextResetAt: null }; + + let earliestRecoveryMs = Infinity; + + for (const a of accounts) { + if (a.isRateLimited) continue; // counts as exhausted, recovery unknown here + const h5 = a.cachedUsagePct; + const d7 = a.cachedUsageD7Pct; + // -1 / undefined means "unknown" → treat as headroom + if (h5 == null || h5 < 0 || d7 == null || d7 < 0) { + return { exhausted: false, nextResetAt: null }; + } + if (Math.max(h5, d7) < CODEX_EXHAUSTION_THRESHOLD_PCT) { + return { exhausted: false, nextResetAt: null }; + } + + const h5ResetMs = + h5 >= CODEX_EXHAUSTION_THRESHOLD_PCT ? parseResetMs(a.resetAt) : null; + const d7ResetMs = + d7 >= CODEX_EXHAUSTION_THRESHOLD_PCT ? parseResetMs(a.resetD7At) : null; + const candidates = [h5ResetMs, d7ResetMs].filter( + (v): v is number => v != null, + ); + if (candidates.length > 0) { + const accountRecovery = Math.max(...candidates); + if (accountRecovery < earliestRecoveryMs) { + earliestRecoveryMs = accountRecovery; + } + } + } + + const nextResetAt = Number.isFinite(earliestRecoveryMs) + ? new Date(earliestRecoveryMs).toISOString() + : null; + return { exhausted: true, nextResetAt }; +} + +/** Backwards-compat boolean wrapper. */ +export function isCodexUsageExhausted(): boolean { + return getCodexUsageExhaustion().exhausted; +} + +function getFnmCodexBinDirs(): string[] { + const fnmRoot = path.join(os.homedir(), '.local', 'share', 'fnm'); + const dirs: string[] = []; + // Prefer the alias `default` first if it exists. + const defaultBin = path.join(fnmRoot, 'aliases', 'default', 'bin'); + if (fs.existsSync(defaultBin)) dirs.push(defaultBin); + // Then fall back to scanning every installed node version. + const versionsRoot = path.join(fnmRoot, 'node-versions'); + try { + if (fs.existsSync(versionsRoot)) { + for (const entry of fs.readdirSync(versionsRoot)) { + const bin = path.join(versionsRoot, entry, 'installation', 'bin'); + if (fs.existsSync(bin)) dirs.push(bin); + } + } + } catch { + /* ignore */ + } + return dirs; +} + function getPreferredCodexPathEntries(): string[] { const entries = [ path.dirname(process.execPath), path.join(os.homedir(), '.npm-global', 'bin'), + // fnm-managed node installs (where `npm i -g @openai/codex` actually + // lands when the host uses fnm). Without these, ejclaw running under + // bun/systemd cannot find the `codex` binary even though the user has + // it installed via fnm's default node. + ...getFnmCodexBinDirs(), ]; if (process.versions.bun || path.basename(process.execPath) === 'bun') { entries.push(path.join(os.homedir(), '.hermes', 'node', 'bin')); @@ -42,6 +152,18 @@ function getPreferredCodexPathEntries(): string[] { return [...new Set(entries)]; } +function findCodexBinary(): string { + const candidates = [ + path.join(os.homedir(), '.npm-global', 'bin', 'codex'), + ...getFnmCodexBinDirs().map((dir) => path.join(dir, 'codex')), + ]; + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + // Last resort: rely on PATH (we extend it via getPreferredCodexPathEntries). + return 'codex'; +} + function getCodexHomeForAccount(accountIndex?: number): string | null { const authPath = getCodexAuthPath(accountIndex); if (!authPath || !fs.existsSync(authPath)) return null; @@ -51,8 +173,7 @@ function getCodexHomeForAccount(accountIndex?: number): string | null { export async function fetchCodexUsage( codexHomeOverride?: string, ): Promise { - const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex'); - const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex'; + const codexBin = findCodexBinary(); return new Promise((resolve) => { let done = false; @@ -197,20 +318,19 @@ export function applyCodexUsageToAccount( const pct = Math.round(effective.primary.usedPercent); const d7Pct = Math.round(effective.secondary.usedPercent); - const resetStr = effective.primary.resetsAt - ? formatResetRemaining(effective.primary.resetsAt) - : undefined; - const resetD7Str = effective.secondary.resetsAt - ? formatResetRemaining(effective.secondary.resetsAt) - : undefined; - updateCodexAccountUsage(pct, resetStr, accountIndex, d7Pct, resetD7Str); + // Store raw ISO timestamps (not pre-formatted strings) so the exhaustion + // gate can compute "minutes until reset" later. The dashboard render path + // formats these at display time via `formatResetRemaining`. + const resetIso = toIsoMaybe(effective.primary.resetsAt); + const resetD7Iso = toIsoMaybe(effective.secondary.resetsAt); + updateCodexAccountUsage(pct, resetIso, accountIndex, d7Pct, resetD7Iso); logger.info( { account: accountIndex + 1, bucket: effective.limitId, h5: pct, d7: d7Pct, - reset: resetStr, + reset: resetIso, }, `Codex account #${accountIndex + 1} usage: 5h=${pct}% 7d=${d7Pct}%`, ); @@ -233,9 +353,9 @@ export function buildCodexUsageRowsFromState(): UsageRow[] { return { name: label, h5pct: acct.cachedUsagePct != null ? acct.cachedUsagePct : -1, - h5reset: acct.resetAt || '', + h5reset: acct.resetAt ? formatResetRemaining(acct.resetAt) : '', d7pct: acct.cachedUsageD7Pct != null ? acct.cachedUsageD7Pct : -1, - d7reset: acct.resetD7At || '', + d7reset: acct.resetD7At ? formatResetRemaining(acct.resetD7At) : '', }; }); } diff --git a/src/config/load-config.ts b/src/config/load-config.ts index cee46da..9586b7e 100644 --- a/src/config/load-config.ts +++ b/src/config/load-config.ts @@ -253,7 +253,7 @@ export function loadConfig(): AppConfig { status: { channelId: readText('STATUS_CHANNEL_ID') ?? '', updateInterval: 10000, - usageUpdateInterval: 300000, + usageUpdateInterval: 60000, showRooms: readBooleanUnlessFalse('STATUS_SHOW_ROOMS', true), showRoomDetails: readBooleanUnlessFalse('STATUS_SHOW_ROOM_DETAILS', true), usageDashboardEnabled: readText('USAGE_DASHBOARD') === 'true', diff --git a/src/data-state-files.test.ts b/src/data-state-files.test.ts index d40d1c7..2163cf0 100644 --- a/src/data-state-files.test.ts +++ b/src/data-state-files.test.ts @@ -28,6 +28,7 @@ describe('listUnexpectedDataStateFiles', () => { fs.writeFileSync(path.join(dataDir, 'codex-warmup-state.json'), '{}'); fs.writeFileSync(path.join(dataDir, 'claude-usage-cache.json'), '{}'); fs.writeFileSync(path.join(dataDir, 'restart-context.abc.json'), '{}'); + fs.writeFileSync(path.join(dataDir, 'auto-paired-mode-state.json'), '{}'); expect(listUnexpectedDataStateFiles(dataDir)).toEqual([]); }); diff --git a/src/data-state-files.ts b/src/data-state-files.ts index db6cff1..8a3f9f3 100644 --- a/src/data-state-files.ts +++ b/src/data-state-files.ts @@ -7,6 +7,7 @@ const ALLOWED_DATA_JSON_PATTERNS = [ /^codex-warmup-state\.json$/, /^claude-usage-cache\.json$/, /^restart-context\..+\.json$/, + /^auto-paired-mode-state\.json$/, ]; function isAllowedDataStateJson(filename: string): boolean { diff --git a/src/db.test.ts b/src/db.test.ts index 8a5a3f0..faff5f2 100644 --- a/src/db.test.ts +++ b/src/db.test.ts @@ -2725,7 +2725,10 @@ describe('room assignment writes', () => { modeSource: 'inferred', name: 'Legacy SQL Room', folder: 'legacy-sql-room', - ownerAgentType: 'codex', + // Owner inference picks OWNER_AGENT_TYPE (env-driven, default codex but + // currently claude-code in this deployment) when both agent types are + // registered. See inferOwnerAgentTypeFromRegisteredAgentTypes. + ownerAgentType: OWNER_AGENT_TYPE, }); expect(getEffectiveRoomMode('dc:legacy-sql')).toBe('tribunal'); expect(getEffectiveRuntimeRoomMode('dc:legacy-sql')).toBe('tribunal'); @@ -3341,7 +3344,8 @@ describe('paired room registration', () => { name: 'Room Settings Test', folder: 'room-settings-test', trigger: '@Codex', - ownerAgentType: 'codex', + // Both agent types registered → owner inference picks OWNER_AGENT_TYPE. + ownerAgentType: OWNER_AGENT_TYPE, }); setExplicitRoomMode('dc:room-settings', 'single'); @@ -3353,7 +3357,7 @@ describe('paired room registration', () => { name: 'Room Settings Test', folder: 'room-settings-test', trigger: '@Codex', - ownerAgentType: 'codex', + ownerAgentType: OWNER_AGENT_TYPE, }); updateRegisteredGroupName('dc:room-settings', 'Room Settings Renamed'); @@ -3365,7 +3369,7 @@ describe('paired room registration', () => { name: 'Room Settings Renamed', folder: 'room-settings-test', trigger: '@Codex', - ownerAgentType: 'codex', + ownerAgentType: OWNER_AGENT_TYPE, }); }); diff --git a/src/db.ts b/src/db.ts index 0732e78..9421683 100644 --- a/src/db.ts +++ b/src/db.ts @@ -11,6 +11,7 @@ export { enforceMemoryBounds, expireStaleMemories, getAllChats, + getEarliestUnansweredHumanSeq, getLastHumanMessageContent, getLastHumanMessageSender, getLastHumanMessageTimestamp, @@ -22,6 +23,7 @@ export { getOpenWorkItem, getOpenWorkItemForChat, getRecentChatMessages, + getRecentDeliveredOwnerWorkItemsForChat, hasRecentRestartAnnouncement, markWorkItemDelivered, markWorkItemDeliveryRetry, diff --git a/src/db/messages.ts b/src/db/messages.ts index 6a2df5c..713a7c2 100644 --- a/src/db/messages.ts +++ b/src/db/messages.ts @@ -332,6 +332,38 @@ export function getRecentChatMessagesFromDatabase( return rows.map(normalizeMessageRow); } +/** + * Returns the seq of the earliest unanswered human message in this chat — + * i.e. the first user message that came after the most recent bot reply. + * Returns null if no such message exists (the user is "caught up"). + * + * Used by restart-recovery to rewind the agent cursor before re-running an + * interrupted turn from the message that initiated it. + */ +export function getEarliestUnansweredHumanSeqFromDatabase( + database: Database, + chatJid: string, +): number | null { + const lastBot = database + .prepare( + `SELECT MAX(seq) AS seq FROM messages + WHERE chat_jid = ? AND is_bot_message = 1`, + ) + .get(chatJid) as { seq: number | null } | undefined; + const lastBotSeq = lastBot?.seq ?? 0; + const row = database + .prepare( + `SELECT MIN(seq) AS seq FROM messages + WHERE chat_jid = ? + AND seq > ? + AND is_bot_message = 0 + AND is_from_me = 0 + AND content != '' AND content IS NOT NULL`, + ) + .get(chatJid, lastBotSeq) as { seq: number | null } | undefined; + return row?.seq ?? null; +} + export function getLastHumanMessageTimestampFromDatabase( database: Database, chatJid: string, diff --git a/src/db/paired-turn-outputs.ts b/src/db/paired-turn-outputs.ts index 01eceda..8d3b6aa 100644 --- a/src/db/paired-turn-outputs.ts +++ b/src/db/paired-turn-outputs.ts @@ -1,7 +1,10 @@ import { Database } from 'bun:sqlite'; import { logger } from '../logger.js'; -import { parseVisibleVerdict } from '../paired-verdict.js'; +import { + parseReviewerVisibleVerdict, + parseVisibleVerdict, +} from '../paired-verdict.js'; import { PairedRoomRole, PairedTurnOutput } from '../types.js'; const MAX_TURN_OUTPUT_CHARS = 50_000; @@ -38,7 +41,9 @@ export function insertPairedTurnOutputInDatabase( turnNumber, role, outputText.slice(0, MAX_TURN_OUTPUT_CHARS), - parseVisibleVerdict(outputText), + role === 'reviewer' + ? parseReviewerVisibleVerdict(outputText) + : parseVisibleVerdict(outputText), createdAt ?? new Date().toISOString(), ); } diff --git a/src/db/runtime-memory-messages.ts b/src/db/runtime-memory-messages.ts index fd484ee..22b5382 100644 --- a/src/db/runtime-memory-messages.ts +++ b/src/db/runtime-memory-messages.ts @@ -17,6 +17,7 @@ import { import { type ChatInfo, getAllChatsFromDatabase, + getEarliestUnansweredHumanSeqFromDatabase, getLastHumanMessageContentFromDatabase, getLastHumanMessageSenderFromDatabase, getLastHumanMessageTimestampFromDatabase, @@ -36,6 +37,7 @@ import { createProducedWorkItemInDatabase, getOpenWorkItemForChatFromDatabase, getOpenWorkItemFromDatabase, + getRecentDeliveredOwnerWorkItemsForChatFromDatabase, markWorkItemDeliveredInDatabase, markWorkItemDeliveryRetryInDatabase, } from './work-items.js'; @@ -223,6 +225,10 @@ export function getLastHumanMessageTimestamp(chatJid: string): string | null { return getLastHumanMessageTimestampFromDatabase(requireDatabase(), chatJid); } +export function getEarliestUnansweredHumanSeq(chatJid: string): number | null { + return getEarliestUnansweredHumanSeqFromDatabase(requireDatabase(), chatJid); +} + export function getLastHumanMessageSender(chatJid: string): string | null { return getLastHumanMessageSenderFromDatabase(requireDatabase(), chatJid); } @@ -266,6 +272,17 @@ export function getOpenWorkItemForChat( ); } +export function getRecentDeliveredOwnerWorkItemsForChat( + chatJid: string, + limit: number, +): WorkItem[] { + return getRecentDeliveredOwnerWorkItemsForChatFromDatabase( + requireDatabase(), + chatJid, + limit, + ); +} + export function createProducedWorkItem( input: CreateProducedWorkItemInput, ): WorkItem { diff --git a/src/db/work-items.ts b/src/db/work-items.ts index 8a9e51c..574e3ee 100644 --- a/src/db/work-items.ts +++ b/src/db/work-items.ts @@ -199,6 +199,34 @@ export function getOpenWorkItemFromDatabase( return row ? hydrateWorkItemRow(row) : undefined; } +/** + * Recent delivered owner-side work items for a chat, newest last. + * Used by single-mode prompt building to surface the bot's own prior + * answers (which only live in work_items.result_payload) so a new turn + * can reference what it previously said. + */ +export function getRecentDeliveredOwnerWorkItemsForChatFromDatabase( + database: Database, + chatJid: string, + limit: number, +): WorkItem[] { + if (limit <= 0) { + return []; + } + const rows = database + .prepare( + `SELECT * + FROM work_items + WHERE chat_jid = ? + AND status = 'delivered' + AND (delivery_role = 'owner' OR delivery_role IS NULL) + ORDER BY id DESC + LIMIT ?`, + ) + .all(chatJid, limit) as StoredWorkItemRow[]; + return rows.map(hydrateWorkItemRow).reverse(); +} + export function getOpenWorkItemForChatFromDatabase( database: Database, chatJid: string, diff --git a/src/index.ts b/src/index.ts index a9dde28..c19a31b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ import { writeGroupsSnapshot } from './agent-runner.js'; import { type AssignRoomInput, getAllTasks, + getEarliestUnansweredHumanSeq, hasRecentRestartAnnouncement, initDatabase, storeChatMetadata, @@ -55,6 +56,7 @@ import { import { createMessageRuntime } from './message-runtime.js'; import { nudgeSchedulerLoop, startSchedulerLoop } from './task-scheduler.js'; import { startUnifiedDashboard } from './unified-dashboard.js'; +import { startUsagePrimer } from './usage-primer.js'; import { startWebDashboardServer } from './web-dashboard-server.js'; import { Channel, NewMessage, RegisteredGroup } from './types.js'; import { logger } from './logger.js'; @@ -270,14 +272,29 @@ async function main(): Promise { status: 'processing' | 'waiting'; } => status.status !== 'inactive', ) - .map((status) => ({ - chatJid: status.jid, - groupName: roomBindings[status.jid]?.name || status.jid, - status: status.status, - elapsedMs: status.elapsedMs, - pendingMessages: status.pendingMessages, - pendingTasks: status.pendingTasks, - })); + .map((status) => { + // Capture the seq of the earliest unanswered human message for this + // chat so the next startup can rewind the agent cursor and re-run + // the interrupted turn automatically. + let rewindToSeq: number | null = null; + try { + rewindToSeq = getEarliestUnansweredHumanSeq(status.jid); + } catch (err) { + logger.warn( + { err, chatJid: status.jid }, + 'Failed to capture rewind seq for shutdown snapshot', + ); + } + return { + chatJid: status.jid, + groupName: roomBindings[status.jid]?.name || status.jid, + status: status.status, + elapsedMs: status.elapsedMs, + pendingMessages: status.pendingMessages, + pendingTasks: status.pendingTasks, + rewindToSeq, + }; + }); const writtenPaths = writeShutdownRestartContext( roomBindings, interruptedGroups, @@ -488,6 +505,37 @@ async function main(): Promise { restartContext, roomBindings, )) { + // Auto-resume: rewind the agent cursor to just before the earliest + // unanswered human message so the missed-message processor picks it up + // again. Claude/Codex sessions are persisted by group folder, so the + // agent will resume mid-turn rather than restarting from scratch. + if (candidate.rewindToSeq != null && candidate.rewindToSeq > 0) { + const cursors = runtimeState.getLastAgentTimestamps(); + const previousCursor = cursors[candidate.chatJid]; + const targetCursor = String(candidate.rewindToSeq - 1); + // Only rewind if the current cursor is past the target — never advance. + const previousNum = previousCursor + ? Number.parseInt(previousCursor, 10) + : 0; + if ( + Number.isFinite(previousNum) && + previousNum >= candidate.rewindToSeq + ) { + cursors[candidate.chatJid] = targetCursor; + runtimeState.saveState(); + logger.info( + { + chatJid: candidate.chatJid, + groupFolder: candidate.groupFolder, + previousCursor, + rewoundTo: targetCursor, + rewindToSeq: candidate.rewindToSeq, + }, + 'Rewound agent cursor for interrupted-turn auto-resume', + ); + } + } + queue.enqueueMessageCheck( candidate.chatJid, resolveGroupIpcPath(candidate.groupFolder), @@ -499,6 +547,7 @@ async function main(): Promise { status: candidate.status, pendingMessages: candidate.pendingMessages, pendingTasks: candidate.pendingTasks, + rewindToSeq: candidate.rewindToSeq, }, 'Queued interrupted group for restart recovery', ); @@ -522,6 +571,7 @@ async function main(): Promise { purgeOnStart: true, }); webDashboardServer = startWebDashboardServer(WEB_DASHBOARD); + startUsagePrimer(); leaseRecoveryTimer = setInterval(() => { const failover = getGlobalFailoverInfo(); diff --git a/src/message-agent-executor-lifecycle.ts b/src/message-agent-executor-lifecycle.ts index 95c63b5..4da981e 100644 --- a/src/message-agent-executor-lifecycle.ts +++ b/src/message-agent-executor-lifecycle.ts @@ -200,6 +200,12 @@ export async function executeMessageAgentAttemptLifecycle(args: { const freshAttempt = await runTrackedAttempt('claude'); if (!isRetryableClaudeSessionFailure(freshAttempt)) { + // The session-poisoning that triggered this recovery was handled by the + // clearStoredSession() above, and the fresh attempt has already + // persisted its own session_id via onPersistSession. Reset the outer + // flag so that finalizePrimaryAttempt won't wipe the freshly persisted + // session based on the previous attempt's stale failure state. + resetSessionRequested = freshAttempt.resetSessionRequested === true; return { attempt: freshAttempt, resolved: null }; } @@ -251,6 +257,11 @@ export async function executeMessageAgentAttemptLifecycle(args: { const freshAttempt = await runTrackedAttempt('codex'); if (!isRetryableCodexSessionFailure(freshAttempt)) { + // See the matching Claude recovery branch above: clear the outer + // reset-session flag so finalizePrimaryAttempt doesn't wipe the + // freshly persisted Codex session based on the previous attempt's + // stale failure state. + resetSessionRequested = freshAttempt.resetSessionRequested === true; return { attempt: freshAttempt, resolved: null }; } diff --git a/src/message-agent-executor-paired.ts b/src/message-agent-executor-paired.ts index 7cbf0ea..64a42e2 100644 --- a/src/message-agent-executor-paired.ts +++ b/src/message-agent-executor-paired.ts @@ -355,7 +355,10 @@ export function createPairedExecutionLifecycle(args: { requiresVisibleVerdict && (!pairedFinalOutput || pairedFinalOutput.length === 0); if (missingVisibleVerdict) { - pairedExecutionSummary = missingVisibleVerdictSummary; + const previousSummary = pairedExecutionSummary?.trim(); + pairedExecutionSummary = previousSummary + ? `${previousSummary}\n\n${missingVisibleVerdictSummary}` + : missingVisibleVerdictSummary; log.warn( { pairedTaskId: pairedExecutionContext?.task.id ?? null, diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 7b2bf63..1fb730c 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -1190,7 +1190,9 @@ describe('runAgentForGroup room memory', () => { taskId: 'paired-task-review-no-verdict', role: 'reviewer', status: 'failed', - summary: 'Execution completed without a visible terminal verdict.', + summary: expect.stringContaining( + 'Execution completed without a visible terminal verdict.', + ), }), ); expect(db.failPairedTurn).toHaveBeenCalledWith({ @@ -1202,7 +1204,9 @@ describe('runAgentForGroup room memory', () => { intentKind: 'reviewer-turn', role: 'reviewer', }, - error: 'Execution completed without a visible terminal verdict.', + error: expect.stringContaining( + 'Execution completed without a visible terminal verdict.', + ), }); expect(db.completePairedTurn).not.toHaveBeenCalled(); expect(db.insertPairedTurnOutput).not.toHaveBeenCalled(); @@ -3459,6 +3463,14 @@ describe('runAgentForGroup Claude rotation', () => { expect(result).toBe('success'); expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2); expect(deps.clearSession).toHaveBeenCalledWith('test-claude'); + // The session must be cleared exactly once (before the fresh retry). + // The fresh retry's newSessionId must end up persisted so the NEXT + // user turn can resume that session. + expect(deps.clearSession).toHaveBeenCalledTimes(1); + expect(deps.persistSession).toHaveBeenCalledWith( + 'test-claude', + 'fresh-session-id', + ); }); it('drops a poisoned Codex session id before retrying a fresh session after remote compaction failure', async () => { @@ -3487,9 +3499,13 @@ describe('runAgentForGroup Claude rotation', () => { vi.mocked(sessionRecovery.shouldRetryFreshCodexSessionOnAgentFailure) .mockReturnValueOnce(true) .mockReturnValueOnce(false); - vi.mocked(sessionRecovery.shouldResetCodexSessionOnAgentFailure) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false); + // The first attempt throws synchronously, so streaming evaluation never + // runs on its (non-existent) output — only the fresh retry's success + // output reaches shouldResetCodexSessionOnAgentFailure, and that output + // does not match any reset pattern. + vi.mocked( + sessionRecovery.shouldResetCodexSessionOnAgentFailure, + ).mockReturnValue(false); vi.mocked(agentRunner.runAgentProcess) .mockImplementationOnce(async (_group, input) => { @@ -3523,6 +3539,13 @@ describe('runAgentForGroup Claude rotation', () => { expect(result).toBe('success'); expect(agentRunner.runAgentProcess).toHaveBeenCalledTimes(2); expect(deps.clearSession).toHaveBeenCalledWith('test-codex'); + // Same invariant as Claude: clearSession runs exactly once and the + // fresh retry's newSessionId is persisted for the next turn. + expect(deps.clearSession).toHaveBeenCalledTimes(1); + expect(deps.persistSession).toHaveBeenCalledWith( + 'test-codex', + 'fresh-codex-session-id', + ); }); it('suppresses a usage-exhausted banner even when Claude already emitted progress text', async () => { diff --git a/src/message-runtime-gating.test.ts b/src/message-runtime-gating.test.ts index 3768541..f6acd03 100644 --- a/src/message-runtime-gating.test.ts +++ b/src/message-runtime-gating.test.ts @@ -9,9 +9,14 @@ vi.mock('./session-commands.js', () => ({ handleSessionCommand: handleSessionCommandMock, })); -import { handleQueuedRunGates } from './message-runtime-gating.js'; +import { + exhaustionChecks, + handleQueuedRunGates, +} from './message-runtime-gating.js'; describe('message-runtime-gating', () => { + const sendMessage = vi.fn(); + const baseArgs = { chatJid: 'room-1', group: { @@ -20,17 +25,21 @@ describe('message-runtime-gating', () => { isMain: false, trigger: '코덱스', added_at: new Date().toISOString(), + agentType: 'claude-code' as const, } satisfies RegisteredGroup, runId: 'run-1', missedMessages: [], triggerPattern: /^코덱스/, timezone: 'Asia/Seoul', hasImplicitContinuationWindow: () => false, - sessionCommandDeps: {} as never, + sessionCommandDeps: { sendMessage } as never, }; beforeEach(() => { handleSessionCommandMock.mockReset(); + sendMessage.mockReset(); + exhaustionChecks.claude = () => ({ exhausted: false, nextResetAt: null }); + exhaustionChecks.codex = () => ({ exhausted: false, nextResetAt: null }); }); it('returns session-command results directly when a command is handled', async () => { @@ -42,13 +51,79 @@ describe('message-runtime-gating', () => { const result = await handleQueuedRunGates(baseArgs); expect(result).toEqual({ handled: true, success: false }); + expect(sendMessage).not.toHaveBeenCalled(); }); - it('falls through when no session command is handled', async () => { + it('falls through when no session command is handled and accounts have headroom', async () => { handleSessionCommandMock.mockResolvedValue({ handled: false }); const result = await handleQueuedRunGates(baseArgs); expect(result).toEqual({ handled: false }); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it('blocks the turn and notifies the channel when Claude is exhausted', async () => { + handleSessionCommandMock.mockResolvedValue({ handled: false }); + const resetAt = new Date(Date.now() + 90 * 60_000).toISOString(); // ~1h30m + exhaustionChecks.claude = () => ({ + exhausted: true, + nextResetAt: resetAt, + }); + + const result = await handleQueuedRunGates(baseArgs); + + expect(result).toEqual({ handled: true, success: true }); + expect(sendMessage).toHaveBeenCalledTimes(1); + const text = sendMessage.mock.calls[0][0]; + expect(text).toMatch(/토큰 부족으로 종료됨/); + expect(text).toMatch(/Claude/); + expect(text).toMatch(/초기화 예정/); + expect(text).toMatch(/1시간/); + }); + + it('blocks the turn and notifies the channel when Codex is exhausted', async () => { + handleSessionCommandMock.mockResolvedValue({ handled: false }); + const resetAt = new Date(Date.now() + 25 * 60_000).toISOString(); // ~25m + exhaustionChecks.codex = () => ({ + exhausted: true, + nextResetAt: resetAt, + }); + + const result = await handleQueuedRunGates({ + ...baseArgs, + group: { ...baseArgs.group, agentType: 'codex' }, + }); + + expect(result).toEqual({ handled: true, success: true }); + expect(sendMessage).toHaveBeenCalledTimes(1); + const text = sendMessage.mock.calls[0][0]; + expect(text).toMatch(/Codex/); + expect(text).toMatch(/약 \d+분 뒤 초기화 예정/); + }); + + it('omits countdown when no reset time is known', async () => { + handleSessionCommandMock.mockResolvedValue({ handled: false }); + exhaustionChecks.claude = () => ({ exhausted: true, nextResetAt: null }); + + const result = await handleQueuedRunGates(baseArgs); + + expect(result).toEqual({ handled: true, success: true }); + const text = sendMessage.mock.calls[0][0]; + expect(text).toMatch(/토큰 부족으로 종료됨/); + expect(text).not.toMatch(/초기화 예정/); + }); + + it('does not cross-block: Codex exhaustion does not stop a Claude room', async () => { + handleSessionCommandMock.mockResolvedValue({ handled: false }); + exhaustionChecks.codex = () => ({ + exhausted: true, + nextResetAt: new Date(Date.now() + 60_000).toISOString(), + }); + + const result = await handleQueuedRunGates(baseArgs); + + expect(result).toEqual({ handled: false }); + expect(sendMessage).not.toHaveBeenCalled(); }); }); diff --git a/src/message-runtime-gating.ts b/src/message-runtime-gating.ts index 977eea1..f023a6d 100644 --- a/src/message-runtime-gating.ts +++ b/src/message-runtime-gating.ts @@ -1,9 +1,80 @@ +import { + CLAUDE_EXHAUSTION_THRESHOLD_PCT, + getClaudeUsageExhaustion, +} from './claude-usage.js'; +import { + CODEX_EXHAUSTION_THRESHOLD_PCT, + getCodexUsageExhaustion, +} from './codex-usage-collector.js'; import { logger } from './logger.js'; import { handleSessionCommand, type SessionCommandDeps, } from './session-commands.js'; -import type { NewMessage, RegisteredGroup } from './types.js'; +import type { AgentType, NewMessage, RegisteredGroup } from './types.js'; + +/** + * Test seam — overridable from unit tests so we don't need a real disk + * cache or rotation state to exercise the exhaustion gate. + */ +export const exhaustionChecks = { + claude: getClaudeUsageExhaustion, + codex: getCodexUsageExhaustion, +}; + +/** Render an ISO timestamp as "약 N분 뒤" / "약 Nh Nm 뒤" relative to now. */ +function formatResetCountdown(iso: string | null): string | null { + if (!iso) return null; + const target = Date.parse(iso); + if (!Number.isFinite(target)) return null; + const diffMs = target - Date.now(); + if (diffMs <= 0) return '곧'; + const totalMinutes = Math.ceil(diffMs / 60_000); + if (totalMinutes < 60) return `약 ${totalMinutes}분 뒤`; + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + if (hours < 24) { + return minutes > 0 + ? `약 ${hours}시간 ${minutes}분 뒤` + : `약 ${hours}시간 뒤`; + } + const days = Math.floor(hours / 24); + const remH = hours % 24; + return remH > 0 ? `약 ${days}일 ${remH}시간 뒤` : `약 ${days}일 뒤`; +} + +function exhaustionMessageFor( + agentType: AgentType, + nextResetAt: string | null, +): string { + const label = agentType === 'codex' ? 'Codex' : 'Claude'; + const pct = + agentType === 'codex' + ? CODEX_EXHAUSTION_THRESHOLD_PCT + : CLAUDE_EXHAUSTION_THRESHOLD_PCT; + const countdown = formatResetCountdown(nextResetAt); + const head = `토큰 부족으로 종료됨 (${label} 모든 계정 사용량 ${pct}% 초과)`; + return countdown ? `${head} — ${countdown} 초기화 예정` : head; +} + +// ── Usage-window alignment gap ── +// Bot primer fires at 08/13/18/23 KST to anchor 5h windows. The 04:00–08:00 +// KST gap before the day's first primer must be free of inference calls, +// otherwise an off-cycle window starts and the daily reset alignment drifts. +// During the gap, send an auto-reply instead of running the model. +// Urgent bypass: any message containing an @-mention (bot's trigger pattern) +// is processed anyway — user explicitly tagged the bot, accept the drift. +const GAP_START_HOUR_KST = 4; +const GAP_END_HOUR_KST = 8; +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + +export function isInUsageAlignmentGap(nowMs: number = Date.now()): boolean { + const kstHour = new Date(nowMs + KST_OFFSET_MS).getUTCHours(); + return kstHour >= GAP_START_HOUR_KST && kstHour < GAP_END_HOUR_KST; +} + +const GAP_AUTO_REPLY = + '사용량 정렬 시간(04~08 KST)입니다. 08시 이후 다시 보내주세요. 긴급한 경우 @태그하면 처리됩니다.'; export async function handleQueuedRunGates(args: { chatJid: string; @@ -31,5 +102,57 @@ export async function handleQueuedRunGates(args: { return cmdResult; } + // ── Usage-window alignment gap gate ── + if (isInUsageAlignmentGap()) { + const hasUrgentMention = args.missedMessages.some((m) => + args.triggerPattern.test(m.content), + ); + if (!hasUrgentMention) { + logger.info( + { + chatJid: args.chatJid, + groupName: args.group.name, + runId: args.runId, + }, + 'Blocking new turn — usage alignment gap (04-08 KST)', + ); + try { + await args.sessionCommandDeps.sendMessage(GAP_AUTO_REPLY); + } catch (err) { + logger.warn({ err }, 'Failed to send alignment gap notice'); + } + return { handled: true, success: true }; + } + } + + // ── Usage-exhaustion gate ── + // Block new turns when the agent provider that would handle this room has + // every account at ≥95% on either window (or rate-limited). In-flight + // runs are not affected; this only stops *new* turns from starting. + const agentType: AgentType = args.group.agentType ?? 'claude-code'; + const info = + agentType === 'codex' + ? exhaustionChecks.codex() + : exhaustionChecks.claude(); + if (info.exhausted) { + const text = exhaustionMessageFor(agentType, info.nextResetAt); + logger.warn( + { + chatJid: args.chatJid, + groupName: args.group.name, + agentType, + runId: args.runId, + nextResetAt: info.nextResetAt, + }, + 'Blocking new turn — all accounts exhausted', + ); + try { + await args.sessionCommandDeps.sendMessage(text); + } catch (err) { + logger.warn({ err }, 'Failed to send exhaustion notice'); + } + return { handled: true, success: true }; + } + return { handled: false }; } diff --git a/src/message-runtime-queue.ts b/src/message-runtime-queue.ts index 4cacf38..834a0c5 100644 --- a/src/message-runtime-queue.ts +++ b/src/message-runtime-queue.ts @@ -1,4 +1,8 @@ -import { getPairedTurnOutputs, getRecentChatMessages } from './db.js'; +import { + getPairedTurnOutputs, + getRecentChatMessages, + getRecentDeliveredOwnerWorkItemsForChat, +} from './db.js'; import { logger } from './logger.js'; import { buildArbiterPromptForTask, @@ -270,10 +274,31 @@ export async function runQueuedGroupTurn(args: { turnOutputs, }); } else { - prompt = args.formatMessages( + // Single-mode (or paired-not-yet-activated) fallback. + // The messages table only stores human messages, so the bot's own prior + // answers (which live in work_items.result_payload) are otherwise invisible + // to the next turn. Prepend the most recent delivered owner answers so the + // model can reference what it previously said (e.g. "translate your last + // answer to Korean"). + const recentBotAnswers = getRecentDeliveredOwnerWorkItemsForChat( + chatJid, + 3, + ); + const formattedNew = args.formatMessages( args.labelPairedSenders(chatJid, missedMessages), args.timezone, ); + if (recentBotAnswers.length === 0) { + prompt = formattedNew; + } else { + const priorBlock = recentBotAnswers + .map((item, idx) => { + const label = `Prior bot answer ${idx + 1}/${recentBotAnswers.length} (delivered ${item.delivered_at ?? item.updated_at})`; + return `${label}:\n${item.result_payload}`; + }) + .join('\n\n'); + prompt = `[Recent prior answers from you in this channel — for context only, do not re-send]\n${priorBlock}\n\n[New messages to respond to]\n${formattedNew}`; + } } const startSeq = missedMessages[0].seq ?? null; diff --git a/src/message-runtime.ts b/src/message-runtime.ts index b2c197a..9f241ab 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -56,7 +56,27 @@ import { } from './types.js'; import { createScopedLogger, logger } from './logger.js'; import { hasReviewerLease } from './service-routing.js'; +import { resolveGroupFolderPath } from './group-folder.js'; import type { WorkItem } from './db/work-items.js'; + +/** + * Build the allow-list of base directories under which an outgoing message's + * attachments may live. Each room is sandboxed to its own group folder + * (`groups/`) by default; if an explicit `workDir` is registered, that + * is added too. This way a screenshot the agent saved under + * `groups//repo/.artifacts/...` is allowed even when the room has no + * workDir configured, while remaining isolated from sibling rooms. + */ +function getGroupAttachmentBaseDirs(group: RegisteredGroup): string[] { + const dirs: string[] = []; + try { + dirs.push(resolveGroupFolderPath(group.folder)); + } catch { + // Invalid folder names should not block delivery; fall through. + } + if (group.workDir) dirs.push(group.workDir); + return dirs; +} export { resolveHandoffCursorKey, resolveHandoffRoleOverride, @@ -255,7 +275,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { channel, item: workItem, log: logger, - attachmentBaseDirs: group.workDir ? [group.workDir] : undefined, + attachmentBaseDirs: getGroupAttachmentBaseDirs(group), replaceMessageId, isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal, openContinuation: (targetChatJid) => @@ -458,7 +478,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { channel, roleToChannel, log, - attachmentBaseDirs: group.workDir ? [group.workDir] : undefined, + attachmentBaseDirs: getGroupAttachmentBaseDirs(group), isPairedRoom: hasReviewerLease(chatJid), getMissingRoleChannelMessage, isDuplicateOfLastBotFinal: checkDuplicateOfLastBotFinal, diff --git a/src/message-turn-controller.test.ts b/src/message-turn-controller.test.ts index a48f153..d1c2490 100644 --- a/src/message-turn-controller.test.ts +++ b/src/message-turn-controller.test.ts @@ -802,4 +802,30 @@ describe('MessageTurnController outbound audit logging', () => { replaceMessageId: 'progress-1', }); }); + + it('publishes a failure final when an error finishes before any visible output', async () => { + const channel = makeChannel(); + const deliverFinalText = vi.fn().mockResolvedValue(true); + const controller = new MessageTurnController({ + chatJid: 'dc:test-room', + group: makeGroup(), + runId: 'run-silent-error-final', + channel, + idleTimeout: 1_000, + failureFinalText: '실패', + isClaudeCodeAgent: true, + clearSession: vi.fn(), + requestClose: vi.fn(), + deliverFinalText, + deliveryRole: 'owner', + }); + + await controller.start(); + const finishResult = await controller.finish('error'); + + expect(finishResult.visiblePhase).toBe('final'); + expect(deliverFinalText).toHaveBeenCalledWith('실패', { + replaceMessageId: null, + }); + }); }); diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index 6e48f8d..be3e426 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -379,6 +379,12 @@ export class MessageTurnController { this.hadError ) { await this.publishFailureFinal(); + } else if ( + outputStatus === 'error' && + this.visiblePhase === 'silent' && + !this.terminalObserved() + ) { + await this.publishFailureFinal(); } this.clearProgressTicker(); diff --git a/src/paired-execution-context-owner.ts b/src/paired-execution-context-owner.ts index beb958a..f5e622c 100644 --- a/src/paired-execution-context-owner.ts +++ b/src/paired-execution-context-owner.ts @@ -12,12 +12,14 @@ import { markPairedTaskReviewReady } from './paired-workspace-manager.js'; import { applyPairedTaskPatch, hasCodeChangesSinceRef, + isUserInputWaitVerdict, parseVisibleVerdict, requestArbiterOrEscalate, resolveOwnerCompletionSignal, resolveCanonicalSourceRef, transitionPairedTaskStatus, } from './paired-execution-context-shared.js'; +import { shouldSkipReviewerForTrivialTurn } from './paired-trivial-turn-detector.js'; import type { PairedTask } from './types.js'; type OwnerFinalizeOutcome = 'stop' | 're_review'; @@ -110,6 +112,29 @@ function handleOwnerFinalizeCompletion(args: { ownerVerdict === 'step_done' && hasNewChanges === false ? (task.empty_step_done_streak ?? 0) + 1 : 0; + + if (isUserInputWaitVerdict(summary)) { + transitionPairedTaskStatus({ + taskId, + currentStatus: task.status, + nextStatus: 'completed', + expectedUpdatedAt: task.updated_at, + updatedAt: now, + patch: { + completion_reason: 'escalated', + owner_failure_count: 0, + owner_step_done_streak: 0, + finalize_step_done_count: nextFinalizeStepDoneCount, + empty_step_done_streak: nextEmptyStepDoneStreak, + }, + }); + logger.info( + { taskId, ownerVerdict, summary: summary?.slice(0, 100) }, + 'Owner finalize is waiting for user input — completing paired task to stop follow-up loop', + ); + return 'stop'; + } + const signal = resolveOwnerCompletionSignal({ phase: 'finalize', visibleVerdict: ownerVerdict, @@ -344,6 +369,27 @@ export function handleOwnerCompletion(args: { const ownerVerdict = parseVisibleVerdict(summary); const nextOwnerStepDoneStreak = ownerVerdict === 'step_done' ? (task.owner_step_done_streak ?? 0) + 1 : 0; + + if (isUserInputWaitVerdict(summary)) { + transitionPairedTaskStatus({ + taskId, + currentStatus: task.status, + nextStatus: 'completed', + expectedUpdatedAt: task.updated_at, + updatedAt: now, + patch: { + completion_reason: 'escalated', + owner_failure_count: 0, + owner_step_done_streak: nextOwnerStepDoneStreak, + }, + }); + logger.info( + { taskId, ownerVerdict, summary: summary?.slice(0, 100) }, + 'Owner is waiting for user input — completing paired task to stop follow-up loop', + ); + return; + } + const signal = resolveOwnerCompletionSignal({ phase: 'normal', visibleVerdict: ownerVerdict, @@ -381,6 +427,31 @@ export function handleOwnerCompletion(args: { }, }); } + + // Skip reviewer for clearly conversational turns (no code changes, + // short non-technical reply, no code-work keywords from the user). + // The detector is best-effort optimization — if anything goes wrong + // we fall back to running the reviewer (the safe pre-change behavior). + let skip = false; + let skipReason = 'evaluation-error'; + try { + const decision = shouldSkipReviewerForTrivialTurn({ task, summary }); + skip = decision.skip; + skipReason = decision.reason; + } catch (err) { + logger.warn( + { err, taskId }, + 'paired-trivial-turn-detector: unexpected evaluation error — running reviewer', + ); + } + if (skip) { + logger.info( + { taskId, reason: skipReason, summary: summary?.slice(0, 100) }, + 'Skipping reviewer for trivial conversational turn', + ); + return; + } + maybeAutoTriggerReviewerAfterOwnerCompletion({ task, taskId, diff --git a/src/paired-execution-context-reviewer.ts b/src/paired-execution-context-reviewer.ts index 939b639..af32f46 100644 --- a/src/paired-execution-context-reviewer.ts +++ b/src/paired-execution-context-reviewer.ts @@ -1,8 +1,9 @@ -import { ARBITER_DEADLOCK_THRESHOLD } from './config.js'; +import { ARBITER_DEADLOCK_THRESHOLD, isArbiterEnabled } from './config.js'; import { getPairedWorkspace } from './db.js'; import { logger } from './logger.js'; import { - parseVisibleVerdict, + parseReviewerVisibleVerdict, + isUserInputWaitVerdict, requestArbiterOrEscalate, resolveReviewerCompletionSignal, resolveReviewerFailureSignal, @@ -11,6 +12,12 @@ import { } from './paired-execution-context-shared.js'; import type { PairedTask } from './types.js'; +function isReviewerUnavailableFailure(summary: string | null | undefined) { + return /(?:\b429\b|usage limit|rate limit|try again at|purchase more credits)/i.test( + summary ?? '', + ); +} + export function handleFailedReviewerExecution(args: { task: PairedTask; taskId: string; @@ -20,7 +27,7 @@ export function handleFailedReviewerExecution(args: { const now = new Date().toISOString(); if (summary) { - const verdict = parseVisibleVerdict(summary); + const verdict = parseReviewerVisibleVerdict(summary); const signal = resolveReviewerFailureSignal({ visibleVerdict: verdict, }); @@ -67,6 +74,28 @@ export function handleFailedReviewerExecution(args: { } } + if (isReviewerUnavailableFailure(summary)) { + transitionPairedTaskStatus({ + taskId, + currentStatus: task.status, + nextStatus: 'completed', + expectedUpdatedAt: task.updated_at, + updatedAt: now, + patch: { + completion_reason: 'escalated', + }, + }); + logger.warn( + { + taskId, + role: 'reviewer', + summary: summary?.slice(0, 160), + }, + 'Reviewer unavailable due to rate/usage limit — stopping retry loop', + ); + return; + } + const fallbackStatus = task.status === 'in_review' || task.status === 'review_ready' ? 'review_ready' @@ -98,11 +127,33 @@ export function handleReviewerCompletion(args: { }): void { const { task, taskId, summary } = args; const now = new Date().toISOString(); - const verdict = parseVisibleVerdict(summary); + const verdict = parseReviewerVisibleVerdict(summary); + const deadlockThreshold = isArbiterEnabled() + ? ARBITER_DEADLOCK_THRESHOLD + : Number.POSITIVE_INFINITY; + + if (isUserInputWaitVerdict(summary)) { + transitionPairedTaskStatus({ + taskId, + currentStatus: task.status, + nextStatus: 'completed', + expectedUpdatedAt: task.updated_at, + updatedAt: now, + patch: { + completion_reason: 'escalated', + }, + }); + logger.info( + { taskId, verdict, summary: summary?.slice(0, 100) }, + 'Reviewer is waiting for user input — completing paired task to stop follow-up loop', + ); + return; + } + const signal = resolveReviewerCompletionSignal({ visibleVerdict: verdict, roundTripCount: task.round_trip_count, - deadlockThreshold: ARBITER_DEADLOCK_THRESHOLD, + deadlockThreshold, }); switch (signal.kind) { diff --git a/src/paired-execution-context-shared.test.ts b/src/paired-execution-context-shared.test.ts index c6ceddf..ed5befe 100644 --- a/src/paired-execution-context-shared.test.ts +++ b/src/paired-execution-context-shared.test.ts @@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'; import { parseVisibleVerdict, + isUserInputWaitVerdict, + parseReviewerVisibleVerdict, resolveOwnerCompletionSignal, resolveReviewerCompletionSignal, resolveReviewerFailureSignal, @@ -11,6 +13,8 @@ describe('paired execution context shared verdict helpers', () => { it('parses visible verdicts from the first summary line only', () => { expect(parseVisibleVerdict('STEP_DONE\nmore to do')).toBe('step_done'); expect(parseVisibleVerdict('TASK_DONE\nall done')).toBe('task_done'); + expect(parseVisibleVerdict('APPROVE\nlooks good')).toBe('task_done'); + expect(parseVisibleVerdict('APPROVED\nlooks good')).toBe('task_done'); expect( parseVisibleVerdict( 'DONE_WITH_CONCERNS\n\nfollow-up detail that should not affect parsing', @@ -18,6 +22,31 @@ describe('paired execution context shared verdict helpers', () => { ).toBe('done_with_concerns'); expect(parseVisibleVerdict('BLOCKED\nextra detail')).toBe('blocked'); expect(parseVisibleVerdict('random prose')).toBe('continue'); + expect(parseVisibleVerdict('PROCEED — arbiter-style text')).toBe( + 'continue', + ); + expect( + parseReviewerVisibleVerdict('PROCEED — review approved, no work left'), + ).toBe('task_done'); + }); + + it('detects STEP_DONE summaries that are only waiting for user input', () => { + expect( + isUserInputWaitVerdict( + 'STEP_DONE — 대기 유지. whoami 결과를 보내주면 바로 SSH 로그인 테스트합니다.', + ), + ).toBe(true); + expect( + isUserInputWaitVerdict( + 'STEP_DONE\n키 등록 끝나면 알려주세요. 그 전까지 더 안 보냅니다.', + ), + ).toBe(true); + expect( + isUserInputWaitVerdict('STEP_DONE\n1단계 완료, 다음 단계 진행합니다.'), + ).toBe(false); + expect( + isUserInputWaitVerdict('TASK_DONE\n사용자 입력 대기 없이 완료'), + ).toBe(false); }); it('maps normal owner completion verdicts to reviewer or arbiter signals', () => { @@ -93,6 +122,13 @@ describe('paired execution context shared verdict helpers', () => { }); it('maps reviewer completion verdicts to finalize, owner changes, or arbiter', () => { + expect( + resolveReviewerCompletionSignal({ + visibleVerdict: parseVisibleVerdict('APPROVE\nverified'), + roundTripCount: 2, + deadlockThreshold: 2, + }), + ).toEqual({ kind: 'request_owner_finalize' }); expect( resolveReviewerCompletionSignal({ visibleVerdict: 'task_done', diff --git a/src/paired-execution-context-shared.ts b/src/paired-execution-context-shared.ts index 1ede0b1..194be9a 100644 --- a/src/paired-execution-context-shared.ts +++ b/src/paired-execution-context-shared.ts @@ -3,7 +3,11 @@ import { execFileSync } from 'child_process'; import { isArbiterEnabled } from './config.js'; import { updatePairedTaskIfUnchanged } from './db.js'; import { logger } from './logger.js'; -import { parseVisibleVerdict, type VisibleVerdict } from './paired-verdict.js'; +import { + parseReviewerVisibleVerdict, + parseVisibleVerdict, + type VisibleVerdict, +} from './paired-verdict.js'; import type { PairedTaskStatus } from './types.js'; export type CompletionSignal = @@ -14,9 +18,36 @@ export type CompletionSignal = | { kind: 'complete'; completionReason: 'done' | 'escalated' } | { kind: 'preserve_review_ready' }; -export { parseVisibleVerdict }; +export { parseReviewerVisibleVerdict, parseVisibleVerdict }; export type { VisibleVerdict }; +export function isUserInputWaitVerdict( + summary: string | null | undefined, +): boolean { + if (parseVisibleVerdict(summary) !== 'step_done') { + return false; + } + + const text = (summary ?? '') + .replace(/[\s\S]*?<\/internal>/g, '') + .trim(); + if (!text) { + return false; + } + + const asksForUserInput = + /(대기|기다리|보내\s*주|알려\s*주|주시면|끝나면|완료되면|그 전까지|더 안 보냅니다|추가 메시지 보류|whoami|사용자명|키\s*등록|공개키|인증 정보 부족)/i.test( + text, + ); + if (!asksForUserInput) { + return false; + } + + return !/(수정|고쳐|고치|변경|구현|빌드|커밋|푸시|계속\s*진행|다음\s*단계\s*진행|후속\s*작업\s*계속|남은\s*(?:범위|작업))/i.test( + text, + ); +} + export function resolveOwnerCompletionSignal(args: { phase: 'normal' | 'finalize'; visibleVerdict: VisibleVerdict; diff --git a/src/paired-execution-context.test.ts b/src/paired-execution-context.test.ts index ba33ec2..eedbd73 100644 --- a/src/paired-execution-context.test.ts +++ b/src/paired-execution-context.test.ts @@ -875,6 +875,34 @@ describe('paired execution context', () => { ).toHaveBeenCalledWith('task-1'); }); + it('completes instead of reviewing when owner STEP_DONE is waiting for user input', () => { + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'active', + round_trip_count: 1, + }), + ); + + completePairedExecutionContext({ + taskId: 'task-1', + role: 'owner', + status: 'succeeded', + summary: + 'STEP_DONE — 대기 유지. whoami 결과를 보내주면 바로 SSH 로그인 테스트합니다.', + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'completed', + completion_reason: 'escalated', + }), + ); + expect( + pairedWorkspaceManager.markPairedTaskReviewReady, + ).not.toHaveBeenCalled(); + }); + it('requests review instead of arbiter when active STEP_DONE repeats without code changes', () => { vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(true); @@ -1323,6 +1351,98 @@ describe('paired execution context', () => { ); }); + it('treats reviewer PROCEED as approval instead of owner-change feedback', () => { + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'in_review', + source_ref: 'reviewed-ref', + }), + ); + + completePairedExecutionContext({ + taskId: 'task-1', + role: 'reviewer', + status: 'succeeded', + summary: 'PROCEED — v0.4.27 종료 상태입니다. 추가 작업 없습니다.', + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'merge_ready', + source_ref: 'reviewed-ref', + }), + ); + expect(db.updatePairedTask).not.toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'active', + }), + ); + }); + + it('completes instead of returning to owner when reviewer STEP_DONE is waiting for user input', () => { + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'in_review', + source_ref: 'reviewed-ref', + }), + ); + + completePairedExecutionContext({ + taskId: 'task-1', + role: 'reviewer', + status: 'succeeded', + summary: + 'STEP_DONE\n\n대기 중입니다. whoami 결과를 보내주면 바로 SSH 접속 테스트합니다.', + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'completed', + completion_reason: 'escalated', + }), + ); + expect(db.updatePairedTask).not.toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'active', + }), + ); + }); + + it('returns reviewer change requests to owner when arbiter is disabled at the deadlock threshold', () => { + vi.spyOn(config, 'isArbiterEnabled').mockReturnValue(false); + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'in_review', + round_trip_count: config.ARBITER_DEADLOCK_THRESHOLD, + }), + ); + + completePairedExecutionContext({ + taskId: 'task-1', + role: 'reviewer', + status: 'succeeded', + summary: 'REVISE\nowner needs another concrete change', + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'active', + }), + ); + expect(db.updatePairedTask).not.toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'completed', + completion_reason: 'escalated', + }), + ); + }); + it('keeps reviewer tasks review_ready when reviewer execution fails without a terminal verdict', () => { vi.mocked(db.getPairedTaskById).mockReturnValue( buildPairedTask({ @@ -1346,6 +1466,30 @@ describe('paired execution context', () => { ); }); + it('escalates reviewer usage-limit failures instead of retrying forever', () => { + vi.mocked(db.getPairedTaskById).mockReturnValue( + buildPairedTask({ + status: 'in_review', + }), + ); + + completePairedExecutionContext({ + taskId: 'task-1', + role: 'reviewer', + status: 'failed', + summary: + "You've hit your usage limit. Upgrade to Pro or try again later.\n\nExecution completed without a visible terminal verdict.", + }); + + expect(db.updatePairedTask).toHaveBeenCalledWith( + 'task-1', + expect.objectContaining({ + status: 'completed', + completion_reason: 'escalated', + }), + ); + }); + it('keeps arbiter tasks arbiter_requested when arbiter execution fails without a terminal verdict', () => { vi.mocked(db.getPairedTaskById).mockReturnValue( buildPairedTask({ diff --git a/src/paired-trivial-turn-detector.test.ts b/src/paired-trivial-turn-detector.test.ts new file mode 100644 index 0000000..db3ba3b --- /dev/null +++ b/src/paired-trivial-turn-detector.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest'; + +import { shouldSkipReviewerForTrivialTurn } from './paired-trivial-turn-detector.js'; +import type { NewMessage, PairedTask } from './types.js'; + +function makeTask(overrides: Partial = {}): PairedTask { + return { + id: 'task-1', + chat_jid: 'dc:111', + group_folder: 'general', + owner_service_id: 'svc-owner', + reviewer_service_id: 'svc-reviewer', + title: null, + source_ref: 'abc123', + plan_notes: null, + review_requested_at: null, + round_trip_count: 0, + status: 'active', + arbiter_verdict: null, + arbiter_requested_at: null, + completion_reason: null, + created_at: '2026-05-06T00:00:00Z', + updated_at: '2026-05-06T00:00:00Z', + ...overrides, + }; +} + +function userMsg(content: string): NewMessage { + return { + id: 'm1', + chat_jid: 'dc:111', + sender: 'user', + sender_name: '사용자', + content, + timestamp: '2026-05-06T00:00:00Z', + is_bot_message: false, + }; +} + +const cleanWorkspace = () => false; + +describe('shouldSkipReviewerForTrivialTurn', () => { + it('skips review for a short conversational reply with no code changes and a chit-chat user message', () => { + const decision = shouldSkipReviewerForTrivialTurn( + { task: makeTask(), summary: '오늘 서울 날씨는 맑음입니다.' }, + { + detectCodeChanges: cleanWorkspace, + recentMessages: () => [userMsg('서울 날씨 어때?')], + }, + ); + expect(decision.skip).toBe(true); + expect(decision.reason).toBe('trivial-turn'); + }); + + it('runs reviewer when the workspace has actual code changes', () => { + const decision = shouldSkipReviewerForTrivialTurn( + { task: makeTask(), summary: '날씨는 맑음' }, + { + detectCodeChanges: () => true, + recentMessages: () => [userMsg('서울 날씨 어때?')], + }, + ); + expect(decision.skip).toBe(false); + expect(decision.reason).toBe('has-code-changes'); + }); + + it('runs reviewer when the owner reply contains a fenced code block', () => { + const decision = shouldSkipReviewerForTrivialTurn( + { + task: makeTask(), + summary: '예시:\n```ts\nconst x = 1;\n```', + }, + { + detectCodeChanges: cleanWorkspace, + recentMessages: () => [userMsg('아무거나')], + }, + ); + expect(decision.skip).toBe(false); + expect(decision.reason).toBe('has-code-block'); + }); + + it('runs reviewer when the owner reply mentions a source file', () => { + const decision = shouldSkipReviewerForTrivialTurn( + { + task: makeTask(), + summary: 'src/db.ts 에서 처리합니다.', + }, + { + detectCodeChanges: cleanWorkspace, + recentMessages: () => [userMsg('아무거나')], + }, + ); + expect(decision.skip).toBe(false); + expect(decision.reason).toBe('mentions-code-file'); + }); + + it('runs reviewer when the user message contains a code-work keyword (Korean)', () => { + const decision = shouldSkipReviewerForTrivialTurn( + { task: makeTask(), summary: '확인했습니다.' }, + { + detectCodeChanges: cleanWorkspace, + recentMessages: () => [userMsg('이거 코드 좀 수정해줘')], + }, + ); + expect(decision.skip).toBe(false); + expect(decision.reason).toContain('user-keyword'); + }); + + it('runs reviewer when the user message contains a code-work keyword (English)', () => { + const decision = shouldSkipReviewerForTrivialTurn( + { task: makeTask(), summary: 'OK' }, + { + detectCodeChanges: cleanWorkspace, + recentMessages: () => [userMsg('please fix the bug')], + }, + ); + expect(decision.skip).toBe(false); + expect(decision.reason).toContain('user-keyword'); + }); + + it('runs reviewer when the verdict is DONE_WITH_CONCERNS', () => { + const decision = shouldSkipReviewerForTrivialTurn( + { + task: makeTask(), + summary: 'DONE_WITH_CONCERNS\nminor issue noticed', + }, + { + detectCodeChanges: cleanWorkspace, + recentMessages: () => [userMsg('서울 날씨 어때?')], + }, + ); + expect(decision.skip).toBe(false); + expect(decision.reason).toBe('verdict-done-with-concerns'); + }); + + it('runs reviewer when the owner reply is unusually long', () => { + const decision = shouldSkipReviewerForTrivialTurn( + { task: makeTask(), summary: 'a'.repeat(900) }, + { + detectCodeChanges: cleanWorkspace, + recentMessages: () => [userMsg('서울 날씨 어때?')], + }, + ); + expect(decision.skip).toBe(false); + expect(decision.reason).toBe('long-summary'); + }); + + it('runs reviewer when the owner reply is empty', () => { + const decision = shouldSkipReviewerForTrivialTurn( + { task: makeTask(), summary: '' }, + { + detectCodeChanges: cleanWorkspace, + recentMessages: () => [userMsg('서울 날씨 어때?')], + }, + ); + expect(decision.skip).toBe(false); + expect(decision.reason).toBe('empty-summary'); + }); + + it('runs reviewer when there is no recent user message (conservative fallback)', () => { + const decision = shouldSkipReviewerForTrivialTurn( + { task: makeTask(), summary: '안녕하세요!' }, + { + detectCodeChanges: cleanWorkspace, + recentMessages: () => [], + }, + ); + expect(decision.skip).toBe(false); + expect(decision.reason).toBe('no-user-message'); + }); + + it('runs reviewer when code-change status cannot be determined', () => { + const decision = shouldSkipReviewerForTrivialTurn( + { task: makeTask(), summary: '오늘 서울 날씨는 맑음입니다.' }, + { + detectCodeChanges: () => null, + recentMessages: () => [userMsg('서울 날씨 어때?')], + }, + ); + expect(decision.skip).toBe(false); + expect(decision.reason).toBe('code-change-status-unknown'); + }); +}); diff --git a/src/paired-trivial-turn-detector.ts b/src/paired-trivial-turn-detector.ts new file mode 100644 index 0000000..b42c290 --- /dev/null +++ b/src/paired-trivial-turn-detector.ts @@ -0,0 +1,214 @@ +/** + * Trivial-turn detector for paired (tribunal) mode. + * + * Goal: skip the reviewer for owner turns that are obviously conversational + * (asking the time, the weather, "what does X mean", chit-chat) so we don't + * burn an extra Claude turn on every trivial reply. + * + * A turn is considered "trivial" only when ALL of the following hold: + * 1. There are no actual code changes in the owner workspace + * (`git diff --quiet HEAD` is clean). + * 2. The owner's reply itself looks conversational — short, no fenced + * code block, no source-file mentions. + * 3. The most recent user message contains no "code-work" keywords + * (수정/구현/빌드/푸시/fix/implement/build/...). + * 4. The owner verdict is not `done_with_concerns` (which explicitly + * asks the reviewer to look again). + * + * If any of these fails, fall back to the normal behavior (run the + * reviewer). Bias toward running the reviewer when uncertain — skipping + * incorrectly could hide a real issue, while reviewing unnecessarily only + * costs tokens. + */ +import { getPairedWorkspace, getRecentChatMessages } from './db.js'; +import { logger } from './logger.js'; +import { + hasCodeChangesSinceRef, + parseVisibleVerdict, +} from './paired-execution-context-shared.js'; +import type { NewMessage, PairedTask } from './types.js'; + +export interface TrivialTurnDeps { + /** Returns true if there are uncommitted/committed code changes since + * the task source ref. Defaults to running `git diff` against the + * paired workspace. Override in tests. */ + detectCodeChanges?: (task: PairedTask) => boolean | null; + /** Returns recent messages for the chat (newest at the end). Defaults + * to `getRecentChatMessages`. Override in tests. */ + recentMessages?: (chatJid: string, limit: number) => NewMessage[]; +} + +const MAX_TRIVIAL_SUMMARY_LENGTH = 800; + +const CODE_WORK_KEYWORDS: readonly string[] = [ + // Korean — code/work intents + '수정', + '변경', + '추가', + '제거', + '삭제', + '만들어', + '구현', + '빌드', + '배포', + '푸시', + '커밋', + '리팩토', + '리팩터', + '리팩', + '버그', + '에러', + '오류', + '고쳐', + '고치', + '등록', + '적용', + '재시작', + '설치', + '테스트', + '실행', + '디버그', + '분석', + '리뷰', + '검증', + '롤백', + '머지', + '코드', + '파일', + '함수', + '모듈', + '커서', + '스크립트', + '의존성', + '패키지', + '컴파일', + '타입', + '인터페이스', + // English + 'fix', + 'implement', + 'create ', + 'build', + 'deploy', + 'push', + 'commit', + 'refactor', + 'bug', + 'error', + 'debug', + 'install', + 'restart', + 'rollback', + 'merge', + 'review', + 'compile', + 'typecheck', + 'lint', + 'pull request', + ' pr ', + 'test ', + 'tests', + ' run ', + 'script', + 'function', + 'module', + 'package', +]; + +const CODE_FILE_EXT_REGEX = + /\b\w+\.(ts|tsx|js|jsx|mjs|cjs|json|py|go|rs|cpp|cc|c|h|hpp|java|kt|swift|rb|php|sh|yml|yaml|toml|md|html|css|scss|sql)\b/i; +const SOURCE_PATH_REGEX = /(?:^|[\s`'"(])(?:src|tests?|scripts?)\/[\w./-]+/; + +export interface TrivialTurnDecision { + skip: boolean; + reason: string; +} + +export function shouldSkipReviewerForTrivialTurn( + args: { + task: PairedTask; + summary?: string | null; + }, + deps: TrivialTurnDeps = {}, +): TrivialTurnDecision { + const verdict = parseVisibleVerdict(args.summary); + if (verdict === 'done_with_concerns') { + return { skip: false, reason: 'verdict-done-with-concerns' }; + } + + // Real code changes always require review. + const detectCodeChanges = deps.detectCodeChanges ?? defaultDetectCodeChanges; + let codeChanges: boolean | null = null; + try { + codeChanges = detectCodeChanges(args.task); + } catch (err) { + logger.warn( + { err, taskId: args.task.id }, + 'paired-trivial-turn-detector: workspace diff lookup failed', + ); + return { skip: false, reason: 'diff-lookup-failed' }; + } + if (codeChanges === true) { + return { skip: false, reason: 'has-code-changes' }; + } + if (codeChanges === null) { + // Couldn't determine — be conservative and run the reviewer. + return { skip: false, reason: 'code-change-status-unknown' }; + } + + const summary = (args.summary ?? '').trim(); + if (summary.length === 0) { + return { skip: false, reason: 'empty-summary' }; + } + if (summary.length > MAX_TRIVIAL_SUMMARY_LENGTH) { + return { skip: false, reason: 'long-summary' }; + } + if (summary.includes('```')) { + return { skip: false, reason: 'has-code-block' }; + } + if (CODE_FILE_EXT_REGEX.test(summary)) { + return { skip: false, reason: 'mentions-code-file' }; + } + if (SOURCE_PATH_REGEX.test(summary)) { + return { skip: false, reason: 'mentions-source-path' }; + } + + // Look at the most recent non-bot message (the user request that + // triggered this owner turn). If it contains code-work keywords, run + // the reviewer. + const recentMessages = deps.recentMessages ?? getRecentChatMessages; + let userText = ''; + try { + const recent = recentMessages(args.task.chat_jid, 10); + for (let i = recent.length - 1; i >= 0; i -= 1) { + const msg = recent[i]; + if (!msg.is_bot_message) { + userText = (msg.content ?? '').toLowerCase(); + break; + } + } + } catch (err) { + logger.warn( + { err, taskId: args.task.id }, + 'paired-trivial-turn-detector: recent message lookup failed', + ); + return { skip: false, reason: 'recent-message-lookup-failed' }; + } + if (!userText) { + // No identifiable user message — be conservative and run the reviewer. + return { skip: false, reason: 'no-user-message' }; + } + for (const keyword of CODE_WORK_KEYWORDS) { + if (userText.includes(keyword.toLowerCase())) { + return { skip: false, reason: `user-keyword:${keyword.trim()}` }; + } + } + + return { skip: true, reason: 'trivial-turn' }; +} + +function defaultDetectCodeChanges(task: PairedTask): boolean | null { + const workspace = getPairedWorkspace(task.id, 'owner'); + if (!workspace?.workspace_dir) return null; + return hasCodeChangesSinceRef(workspace.workspace_dir, task.source_ref); +} diff --git a/src/paired-verdict.ts b/src/paired-verdict.ts index a2eef37..54e69ee 100644 --- a/src/paired-verdict.ts +++ b/src/paired-verdict.ts @@ -7,17 +7,23 @@ export type VisibleVerdict = | 'needs_context' | 'continue'; +function firstVisibleLine(summary: string | null | undefined): string | null { + if (!summary) return null; + const cleaned = summary.replace(/[\s\S]*?<\/internal>/g, '').trim(); + if (!cleaned) return null; + return cleaned.split('\n')[0].trim(); +} + export function parseVisibleVerdict( summary: string | null | undefined, ): VisibleVerdict { - if (!summary) return 'continue'; - const cleaned = summary.replace(/[\s\S]*?<\/internal>/g, '').trim(); - if (!cleaned) return 'continue'; - const firstLine = cleaned.split('\n')[0].trim(); + const firstLine = firstVisibleLine(summary); + if (!firstLine) return 'continue'; if (/^\*{0,2}BLOCKED\*{0,2}\b/i.test(firstLine)) return 'blocked'; if (/^\*{0,2}NEEDS_CONTEXT\*{0,2}\b/i.test(firstLine)) return 'needs_context'; if (/^\*{0,2}STEP_DONE\*{0,2}\b/i.test(firstLine)) return 'step_done'; if (/^\*{0,2}TASK_DONE\*{0,2}\b/i.test(firstLine)) return 'task_done'; + if (/^\*{0,2}APPROV(?:E|ED)\.?\*{0,2}\b/i.test(firstLine)) return 'task_done'; if (/^\*{0,2}DONE_WITH_CONCERNS\*{0,2}\b/i.test(firstLine)) return 'done_with_concerns'; if (/^\*{0,2}DONE\*{0,2}\b/i.test(firstLine)) return 'done'; @@ -26,6 +32,20 @@ export function parseVisibleVerdict( return 'continue'; } +export function parseReviewerVisibleVerdict( + summary: string | null | undefined, +): VisibleVerdict { + const verdict = parseVisibleVerdict(summary); + if (verdict !== 'continue') return verdict; + + const firstLine = firstVisibleLine(summary); + if (/^\*{0,2}PROCEED\.?\*{0,2}\b/i.test(firstLine ?? '')) { + return 'task_done'; + } + + return verdict; +} + export function resolveStoredVisibleVerdict(args: { verdict?: VisibleVerdict | null; outputText?: string | null; diff --git a/src/restart-context.test.ts b/src/restart-context.test.ts index 2337f44..a4b172c 100644 --- a/src/restart-context.test.ts +++ b/src/restart-context.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + buildInterruptedRestartAnnouncement, getInterruptedRecoveryCandidates, type RestartContext, } from './restart-context.js'; @@ -36,6 +37,7 @@ describe('getInterruptedRecoveryCandidates', () => { elapsedMs: 1000, pendingMessages: true, pendingTasks: 0, + rewindToSeq: 42, }, { chatJid: 'dc:1', @@ -71,6 +73,7 @@ describe('getInterruptedRecoveryCandidates', () => { status: 'processing', pendingMessages: true, pendingTasks: 0, + rewindToSeq: 42, }, { chatJid: 'dc:2', @@ -78,6 +81,7 @@ describe('getInterruptedRecoveryCandidates', () => { status: 'idle', pendingMessages: false, pendingTasks: 0, + rewindToSeq: null, }, ]); }); @@ -86,3 +90,33 @@ describe('getInterruptedRecoveryCandidates', () => { expect(getInterruptedRecoveryCandidates(null, {})).toEqual([]); }); }); + +describe('buildInterruptedRestartAnnouncement', () => { + it('promises auto-resume when a rewind seq is captured', () => { + const message = buildInterruptedRestartAnnouncement({ + chatJid: 'dc:1', + groupName: 'room', + status: 'processing', + elapsedMs: 12000, + pendingMessages: true, + pendingTasks: 0, + rewindToSeq: 100, + }); + expect(message).toContain('서비스 재시작으로 이전 작업이 중단됐습니다.'); + expect(message).toContain('자동으로 이어갑니다'); + expect(message).not.toContain('필요하면 이어서'); + }); + + it('falls back to manual prompt when no rewind seq exists', () => { + const message = buildInterruptedRestartAnnouncement({ + chatJid: 'dc:1', + groupName: 'room', + status: 'waiting', + elapsedMs: null, + pendingMessages: false, + pendingTasks: 0, + }); + expect(message).toContain('필요하면 이어서'); + expect(message).not.toContain('자동으로 이어갑니다'); + }); +}); diff --git a/src/restart-context.ts b/src/restart-context.ts index f2aaedc..ce5a816 100644 --- a/src/restart-context.ts +++ b/src/restart-context.ts @@ -14,6 +14,12 @@ export interface RestartInterruptedGroup { elapsedMs: number | null; pendingMessages: boolean; pendingTasks: number; + /** + * Seq of the earliest unanswered human message at shutdown time. If + * present, startup will rewind the agent cursor to (this seq − 1) so the + * interrupted turn re-runs automatically. Null when nothing was waiting. + */ + rewindToSeq?: number | null; } export interface RestartContext { @@ -37,6 +43,7 @@ export interface RestartRecoveryCandidate { status: RestartInterruptedGroup['status']; pendingMessages: boolean; pendingTasks: number; + rewindToSeq: number | null; } const INFER_WINDOW_MS = 3 * 60 * 1000; @@ -197,7 +204,11 @@ export function buildInterruptedRestartAnnouncement( if (interrupted.pendingTasks > 0) { lines.push(`- 대기 태스크: ${interrupted.pendingTasks}개`); } - lines.push('- 필요하면 이어서 요청해 주세요.'); + if (interrupted.rewindToSeq != null) { + lines.push('- 중단된 작업을 자동으로 이어갑니다.'); + } else { + lines.push('- 필요하면 이어서 요청해 주세요.'); + } return lines.join('\n'); } @@ -251,6 +262,7 @@ export function getInterruptedRecoveryCandidates( status: interrupted.status, pendingMessages: interrupted.pendingMessages, pendingTasks: interrupted.pendingTasks, + rewindToSeq: interrupted.rewindToSeq ?? null, }); } diff --git a/src/router.test.ts b/src/router.test.ts index f97b6ca..81c29c2 100644 --- a/src/router.test.ts +++ b/src/router.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest'; import { findChannelForDeliveryRole, + formatOutbound, + neutralizeStrayBackticks, + neutralizeStrayMarkdown, resolveChannelForDeliveryRole, } from './router.js'; import { type Channel } from './types.js'; @@ -92,3 +95,134 @@ describe('findChannelForDeliveryRole', () => { ); }); }); + +describe('neutralizeStrayMarkdown', () => { + it('strips inline backticks', () => { + expect(neutralizeStrayMarkdown('use `foo` and `bar` here')).toBe( + 'use foo and bar here', + ); + }); + + it('preserves well-formed fenced code blocks', () => { + const input = 'before\n```ts\nconst x = `tpl`;\n```\nafter `inline`'; + expect(neutralizeStrayMarkdown(input)).toBe( + 'before\n```ts\nconst x = `tpl`;\n```\nafter inline', + ); + }); + + it('strips emphasis in an unterminated fence', () => { + expect(neutralizeStrayMarkdown('oops ```code without close')).toBe( + 'oops code without close', + ); + }); + + it('passes through plain text unchanged', () => { + const s = 'plain text with no markup'; + expect(neutralizeStrayMarkdown(s)).toBe(s); + }); + + it('strips italic asterisks', () => { + expect(neutralizeStrayMarkdown('이건 *기울임* 처리')).toBe( + '이건 기울임 처리', + ); + }); + + it('strips bold asterisks', () => { + expect(neutralizeStrayMarkdown('이건 **강조** 처리')).toBe( + '이건 강조 처리', + ); + }); + + it('strips bold-italic triple asterisks', () => { + expect(neutralizeStrayMarkdown('***hybrid***')).toBe('hybrid'); + }); + + it('strips italic underscores at word boundaries', () => { + expect(neutralizeStrayMarkdown('이건 _italic_ 처리')).toBe( + '이건 italic 처리', + ); + }); + + it('preserves single underscores in snake_case identifiers', () => { + expect(neutralizeStrayMarkdown('use foo_bar_baz here')).toBe( + 'use foo_bar_baz here', + ); + }); + + it('escapes underscores in absolute paths so Discord does not italicize them', () => { + expect( + neutralizeStrayMarkdown( + '경로: /home/claude/EJClaw/groups/mc_the_scene_plugin/src/main_file.ts', + ), + ).toBe( + '경로: /home/claude/EJClaw/groups/mc\\_the\\_scene\\_plugin/src/main\\_file.ts', + ); + }); + + it('escapes underscores in Windows paths', () => { + expect( + neutralizeStrayMarkdown( + '경로: C:\\Custom_vscode\\minecraft_launcher\\src\\main_file.ts', + ), + ).toBe( + '경로: C:\\Custom\\_vscode\\minecraft\\_launcher\\src\\main\\_file.ts', + ); + }); + + it('strips double-underscore underline markers', () => { + expect(neutralizeStrayMarkdown('__bold__ text')).toBe('bold text'); + }); + + it('strips heading hashes at line start', () => { + expect(neutralizeStrayMarkdown('## 빌드 타임 메타\n본문')).toBe( + '빌드 타임 메타\n본문', + ); + }); + + it('strips block-quote markers', () => { + expect(neutralizeStrayMarkdown('> quoted line\n> another')).toBe( + 'quoted line\nanother', + ); + }); + + it('strips strikethrough', () => { + expect(neutralizeStrayMarkdown('이건 ~~취소선~~ 입니다')).toBe( + '이건 취소선 입니다', + ); + }); + + it('strips spoiler markers', () => { + expect(neutralizeStrayMarkdown('||비밀||')).toBe('비밀'); + }); + + it('preserves Discord mention syntax', () => { + expect(neutralizeStrayMarkdown('<@123> hi <#456>')).toBe( + '<@123> hi <#456>', + ); + }); + + it('preserves code fence content while stripping prose emphasis', () => { + const input = '**bold** outside\n```py\nx = *2\n```\nand *more*'; + expect(neutralizeStrayMarkdown(input)).toBe( + 'bold outside\n```py\nx = *2\n```\nand more', + ); + }); + + it('back-compat alias still exported', () => { + expect(neutralizeStrayBackticks('*x*')).toBe('x'); + }); +}); + +describe('formatOutbound', () => { + it('runs the full pipeline and strips stray backticks', () => { + const raw = 'noteUse `foo` in your code.'; + expect(formatOutbound(raw)).toBe('Use foo in your code.'); + }); + + it('preserves fenced code blocks through the pipeline', () => { + const raw = 'See:\n```js\nconst a = 1;\n```\nor `inline`.'; + expect(formatOutbound(raw)).toBe( + 'See:\n```js\nconst a = 1;\n```\nor inline.', + ); + }); +}); diff --git a/src/router.ts b/src/router.ts index 5cbc161..0a05343 100644 --- a/src/router.ts +++ b/src/router.ts @@ -78,12 +78,90 @@ export function stripToolCallLeaks(text: string): string { return stripped.replace(/\n{3,}/g, '\n\n').trim(); } +function escapePathMarkdownUnderscores(segment: string): string { + return segment.replace( + /(^|[\s[{<])((?:[A-Za-z]:[\\/]|~?[\\/]|\.{1,2}[\\/])\S*_\S*_\S*)/g, + (_match, prefix: string, filePath: string) => + `${prefix}${filePath.replace(/_/g, '\\_')}`, + ); +} + +/** + * Strip Discord markdown emphasis/heading markers that mangle plain-prose + * output when wrapping across lines. Used inside non-code segments only — + * callers should preserve fenced code blocks separately. + * + * Discord rendering reference: + * *italic*, _italic_, **bold**, ***bold-italic*** + * __underline__ ~~strike~~ ||spoiler|| + * # H1 ## H2 ### H3 (at line start) + * > quote, >>> multi-line quote (at line start) + * + * `<@id>`/`<#id>`/`<:emoji:id>` mentions and `[text](url)` links are left + * untouched (handled elsewhere or harmless as raw text). + */ +function stripMarkdownInProse(segment: string): string { + return ( + escapePathMarkdownUnderscores(segment) + // Inline backtick code — agents abuse this to highlight identifiers + // and Discord then fragments coloring across line wraps. + .replace(/`/g, '') + // Strikethrough and spoiler wrappers (always paired punctuation) + .replace(/~~/g, '') + .replace(/\|\|/g, '') + // Heading markers at the start of a line (after optional whitespace) + .replace(/^[ \t]*#{1,3}[ \t]+/gm, '') + // Block quote markers at the start of a line + .replace(/^[ \t]*>{1,3}[ \t]?/gm, '') + // Bold/italic asterisks — Discord italicizes even mid-word (a*b*c), + // so just remove every '*'. Math/code uses live in fenced blocks. + .replace(/\*/g, '') + // Underline/bold uses '__'; remove paired runs of 2+ underscores. + .replace(/_{2,}/g, '') + // Italic '_word_' — only strip when the underscores are at word + // boundaries (so snake_case identifiers survive intact). + .replace(/(^|[^A-Za-z0-9_])_([^_\n]+?)_(?=$|[^A-Za-z0-9_])/g, '$1$2') + ); +} + +/** + * Neutralize stray Discord markdown in prose while preserving well-formed + * triple-backtick fenced code blocks (legit code snippets). + */ +export function neutralizeStrayMarkdown(text: string): string { + if (!text) return text; + const parts: string[] = []; + let i = 0; + while (i < text.length) { + const fenceStart = text.indexOf('```', i); + if (fenceStart === -1) { + parts.push(stripMarkdownInProse(text.slice(i))); + break; + } + parts.push(stripMarkdownInProse(text.slice(i, fenceStart))); + const fenceEnd = text.indexOf('```', fenceStart + 3); + if (fenceEnd === -1) { + // Unterminated fence — not a real code block; treat as prose. + parts.push(stripMarkdownInProse(text.slice(fenceStart))); + break; + } + // Preserve the entire fenced block including delimiters. + parts.push(text.slice(fenceStart, fenceEnd + 3)); + i = fenceEnd + 3; + } + return parts.join(''); +} + +/** @deprecated Kept for back-compat; use neutralizeStrayMarkdown. */ +export const neutralizeStrayBackticks = neutralizeStrayMarkdown; + export function formatOutbound(rawText: string): string { let text = stripInternalTags(rawText); if (!text) return ''; text = stripToolCallLeaks(text); if (!text) return ''; - return redactSecrets(text); + text = redactSecrets(text); + return neutralizeStrayMarkdown(text); } export function findChannel( diff --git a/src/types.ts b/src/types.ts index 07f72f6..cab64be 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,14 @@ export interface AgentConfig { claudeEffort?: string; claudeThinking?: 'adaptive' | 'enabled' | 'disabled'; claudeThinkingBudget?: number; + // Per-role overrides for paired rooms. When a roomRoleContext is supplied + // these take precedence over the flat claudeModel/claudeEffort/codexModel/ + // codexEffort fields. Useful for spending Opus only on arbiter judgments + // while keeping owner/reviewer on Sonnet. + claudeModelByRole?: Partial>; + claudeEffortByRole?: Partial>; + codexModelByRole?: Partial>; + codexEffortByRole?: Partial>; } export type AgentType = 'claude-code' | 'codex'; @@ -40,6 +48,21 @@ export interface SendMessageOptions { * the global Discord attachment allowlist. */ attachmentBaseDirs?: string[]; + /** + * If true, skip Discord markdown neutralization (used by the status + * dashboard and other callers that build pre-formatted content with + * intentional bold/italic/heading markup). Default: false. + */ + rawMarkdown?: boolean; +} + +export interface EditMessageOptions { + /** + * If true, skip Discord markdown neutralization for this edit. Default: + * false — the streaming edit path will sanitize agent prose the same way + * as the initial send. + */ + rawMarkdown?: boolean; } export type PairedRoomRole = 'owner' | 'reviewer' | 'arbiter'; @@ -264,9 +287,18 @@ export interface Channel { // Optional: typing indicator. Channels that support it implement it. setTyping?(jid: string, isTyping: boolean): Promise; // Optional: edit/delete messages (used by status dashboard and tracked progress cleanup). - editMessage?(jid: string, messageId: string, text: string): Promise; + editMessage?( + jid: string, + messageId: string, + text: string, + options?: EditMessageOptions, + ): Promise; deleteMessage?(jid: string, messageId: string): Promise; - sendAndTrack?(jid: string, text: string): Promise; + sendAndTrack?( + jid: string, + text: string, + options?: EditMessageOptions, + ): Promise; // Optional: sync group/chat names from the platform. syncGroups?(force: boolean): Promise; // Optional: get channel metadata (position, category) for ordering. diff --git a/src/unified-dashboard.test.ts b/src/unified-dashboard.test.ts index 6e77084..3135905 100644 --- a/src/unified-dashboard.test.ts +++ b/src/unified-dashboard.test.ts @@ -89,23 +89,101 @@ describe('renderUsageTable', () => { expect(codexIdx).toBeGreaterThan(sepIdx); }); - it('omits separator when only Claude rows exist', () => { + it('shows "Codex: 조회 불가" when only Claude rows exist', () => { const lines = renderUsageTable([claudeRow], []); - expect(lines.some((l) => /^─+$/.test(l))).toBe(false); expect(lines.some((l) => l.includes('Claude'))).toBe(true); + expect( + lines.some((l) => l.includes('Codex') && l.includes('조회 불가')), + ).toBe(true); }); - it('omits separator when only Codex rows exist', () => { + it('shows "Claude: 조회 불가" when only Codex rows exist', () => { const lines = renderUsageTable([], [codexRow]); - expect(lines.some((l) => /^─+$/.test(l))).toBe(false); + expect( + lines.some((l) => l.includes('Claude') && l.includes('조회 불가')), + ).toBe(true); expect(lines.some((l) => l.includes('Codex'))).toBe(true); }); - it('returns fallback text when both groups are empty', () => { + it('shows separate 조회 불가 for both when both groups are empty', () => { const lines = renderUsageTable([], []); - expect(lines).toEqual(['_조회 불가_']); + expect( + lines.some((l) => l.includes('Claude') && l.includes('조회 불가')), + ).toBe(true); + expect( + lines.some((l) => l.includes('Codex') && l.includes('조회 불가')), + ).toBe(true); + }); + + it('shows "Claude: 사용량 없음" when all Claude rows are at >=99% in a window', () => { + const exhaustedClaude: UsageRow = { + name: 'Claude pro', + h5pct: 99, + h5reset: '', + d7pct: 30, + d7reset: '', + }; + const lines = renderUsageTable([exhaustedClaude], [codexRow]); + + expect( + lines.some((l) => l.includes('Claude') && l.includes('사용량 없음')), + ).toBe(true); + // Codex side should still render normally + expect(lines.some((l) => l.includes('Codex') && /\d+%/.test(l))).toBe(true); + }); + + it('shows "Codex: 사용량 없음" when all Codex rows are at 100% in either window', () => { + const exhaustedCodex: UsageRow = { + name: 'Codex', + h5pct: 100, + h5reset: '', + d7pct: 50, + d7reset: '', + }; + const lines = renderUsageTable([claudeRow], [exhaustedCodex]); + + expect( + lines.some((l) => l.includes('Codex') && l.includes('사용량 없음')), + ).toBe(true); + }); + + it('treats <99% rows as having capacity remaining', () => { + const almostExhausted: UsageRow = { + name: 'Claude pro', + h5pct: 98, + h5reset: '', + d7pct: 50, + d7reset: '', + }; + const lines = renderUsageTable([almostExhausted], [codexRow]); + + // Should render the row as data, not the "사용량 없음" status line + expect(lines.some((l) => l.includes('사용량 없음'))).toBe(false); + }); + + it('treats source as exhausted only when ALL its rows are exhausted', () => { + const exhausted: UsageRow = { + name: 'Claude max', + h5pct: 100, + h5reset: '', + d7pct: 100, + d7reset: '', + }; + const fresh: UsageRow = { + name: 'Claude pro', + h5pct: 10, + h5reset: '', + d7pct: 5, + d7reset: '', + }; + const lines = renderUsageTable([exhausted, fresh], [codexRow]); + + // Mixed exhausted/fresh → render rows normally, no 사용량 없음 banner + expect(lines.some((l) => l.includes('사용량 없음'))).toBe(false); + expect(lines.some((l) => l.includes('Claude max'))).toBe(true); + expect(lines.some((l) => l.includes('Claude pro'))).toBe(true); }); }); diff --git a/src/unified-dashboard.ts b/src/unified-dashboard.ts index 86113ba..5172064 100644 --- a/src/unified-dashboard.ts +++ b/src/unified-dashboard.ts @@ -34,6 +34,7 @@ import { refreshAllCodexAccountUsage, } from './codex-usage-collector.js'; import { runCodexWarmupCycle } from './codex-warmup.js'; +import { evaluateAndApplyAutoPairedMode } from './auto-paired-mode.js'; import { composeDashboardContent, formatElapsed, @@ -448,12 +449,44 @@ function buildStatusContent(): string { * Ordering: Claude rows → separator → Codex rows. * Exported for testing. */ +/** + * A row is considered "exhausted" (1% or less remaining in any window) when + * either the 5h or 7d utilization is at or above 99%. Rows where a window's + * percentage is unknown (h5pct/d7pct < 0) aren't counted toward exhaustion + * for that window. + */ +function isExhaustedRow(row: UsageRow): boolean { + const h5Out = row.h5pct >= 99; + const d7Out = row.d7pct >= 99; + return h5Out || d7Out; +} + +function allRowsExhausted(rows: UsageRow[]): boolean { + if (rows.length === 0) return false; + return rows.every(isExhaustedRow); +} + export function renderUsageTable( claudeBotRows: UsageRow[], codexBotRows: UsageRow[], ): string[] { - const allRows = [...claudeBotRows, ...codexBotRows]; - if (allRows.length === 0) return ['_조회 불가_']; + // Per-source status: 조회 불가 (no rows) vs 사용량 없음 (all exhausted) + const claudeStatus: 'ok' | 'unavailable' | 'exhausted' = + claudeBotRows.length === 0 + ? 'unavailable' + : allRowsExhausted(claudeBotRows) + ? 'exhausted' + : 'ok'; + const codexStatus: 'ok' | 'unavailable' | 'exhausted' = + codexBotRows.length === 0 + ? 'unavailable' + : allRowsExhausted(codexBotRows) + ? 'exhausted' + : 'ok'; + + const renderableClaude = claudeStatus === 'ok' ? claudeBotRows : []; + const renderableCodex = codexStatus === 'ok' ? codexBotRows : []; + const allRenderable = [...renderableClaude, ...renderableCodex]; const bar = (pct: number) => { const filled = Math.max(0, Math.min(5, Math.round(pct / 20))); @@ -463,12 +496,15 @@ export function renderUsageTable( const visualWidth = (s: string) => [...s].reduce((w, c) => w + (c.codePointAt(0)! > 0x7f ? 2 : 1), 0); const maxNameWidth = - Math.max(8, ...allRows.map((r) => visualWidth(r.name))) + 1; + Math.max(8, ...allRenderable.map((r) => visualWidth(r.name))) + 1; const padName = (s: string) => s + ' '.repeat(Math.max(0, maxNameWidth - visualWidth(s))); const compactReset = (s: string) => s ? s.replace(/\s+/g, '').replace(/m$/, '') : ''; + const statusLabel = (status: 'unavailable' | 'exhausted'): string => + status === 'unavailable' ? '조회 불가' : '사용량 없음'; + const lines: string[] = []; const renderRows = (rows: UsageRow[]) => { @@ -498,14 +534,22 @@ export function renderUsageTable( lines.push('```'); lines.push(`${' '.repeat(maxNameWidth)}5h 7d`); - renderRows(claudeBotRows); - - if (claudeBotRows.length > 0 && codexBotRows.length > 0) { - const separatorWidth = maxNameWidth + 20; - lines.push('─'.repeat(separatorWidth)); + // Claude section + if (claudeStatus === 'ok') { + renderRows(claudeBotRows); + } else { + lines.push(`${padName('Claude')}${statusLabel(claudeStatus)}`); } - renderRows(codexBotRows); + const separatorWidth = maxNameWidth + 20; + lines.push('─'.repeat(separatorWidth)); + + // Codex section + if (codexStatus === 'ok') { + renderRows(codexBotRows); + } else { + lines.push(`${padName('Codex')}${statusLabel(codexStatus)}`); + } lines.push('```'); @@ -751,9 +795,13 @@ export async function startUnifiedDashboard( } if (statusMessageId && channel.editMessage) { - await channel.editMessage(statusJid, statusMessageId, content); + await channel.editMessage(statusJid, statusMessageId, content, { + rawMarkdown: true, + }); } else if (channel.sendAndTrack) { - const id = await channel.sendAndTrack(statusJid, content); + const id = await channel.sendAndTrack(statusJid, content, { + rawMarkdown: true, + }); if (id) statusMessageId = id; } if (!dashboardUpdateLogged) { @@ -773,7 +821,29 @@ export async function startUnifiedDashboard( await updateStatus(); if (isRenderer) { - setInterval(refreshUsageCache, RENDERER_USAGE_REFRESH_MS); + const evaluatePairedMode = async () => { + try { + await evaluateAndApplyAutoPairedMode({ + queue: opts.queue, + sendNotice: async (jid, text) => { + const ch = opts.channels.find( + (c) => + c.name.startsWith('discord') && + c.isConnected() && + c.ownsJid(jid), + ); + if (ch) await ch.sendMessage(jid, text); + }, + }); + } catch (err) { + logger.warn({ err }, 'auto-paired-mode evaluation failed'); + } + }; + setInterval(async () => { + await refreshUsageCache(); + await evaluatePairedMode(); + }, RENDERER_USAGE_REFRESH_MS); + await evaluatePairedMode(); } // Codex usage collection — runs in unified service regardless of renderer role. diff --git a/src/usage-exhaustion.test.ts b/src/usage-exhaustion.test.ts new file mode 100644 index 0000000..55b148e --- /dev/null +++ b/src/usage-exhaustion.test.ts @@ -0,0 +1,336 @@ +/** + * Unit tests for the 95% usage-exhaustion helpers (Claude + Codex). + * + * These do not exercise the real disk cache or rotation state; they mock + * just enough to verify the gate's truth-table: + * + * - no accounts → false (don't block) + * - missing data on any → false (don't block) + * - rate-limited account → counts as exhausted + * - any account < 95% → false (route there instead) + * - all accounts ≥ 95% → true, with earliest expected recovery time + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('./config.js', () => ({ + DATA_DIR: '/tmp/ejclaw-usage-exhaustion-data', +})); + +const { getAllTokensMock } = vi.hoisted(() => ({ + getAllTokensMock: vi.fn(), +})); +vi.mock('./token-rotation.js', () => ({ + getAllTokens: getAllTokensMock, + getCurrentToken: () => null, + getConfiguredClaudeTokens: () => [], +})); + +const { getAllCodexAccountsMock } = vi.hoisted(() => ({ + getAllCodexAccountsMock: vi.fn(), +})); +vi.mock('./codex-token-rotation.js', () => ({ + getAllCodexAccounts: getAllCodexAccountsMock, + getCodexAuthPath: () => null, + updateCodexAccountUsage: vi.fn(), +})); + +const { readJsonFileMock } = vi.hoisted(() => ({ + readJsonFileMock: vi.fn(), +})); +vi.mock('./utils.js', async () => { + const actual = + await vi.importActual('./utils.js'); + return { + ...actual, + readJsonFile: readJsonFileMock, + writeJsonFile: vi.fn(), + }; +}); + +afterEach(() => { + vi.resetModules(); + vi.clearAllMocks(); +}); + +describe('getClaudeUsageExhaustion', () => { + beforeEach(() => { + readJsonFileMock.mockReturnValue({}); + }); + + it('returns exhausted=false when no tokens are configured', async () => { + getAllTokensMock.mockReturnValue([]); + const { getClaudeUsageExhaustion } = await import('./claude-usage.js'); + expect(getClaudeUsageExhaustion()).toEqual({ + exhausted: false, + nextResetAt: null, + }); + }); + + it('returns exhausted=false when an account has no cached usage', async () => { + getAllTokensMock.mockReturnValue([ + { + token: 'sk-ant-oat01-aaaaaaaa', + index: 0, + masked: 'aaa', + isActive: true, + isRateLimited: false, + }, + ]); + readJsonFileMock.mockReturnValue({}); // empty cache + const { getClaudeUsageExhaustion } = await import('./claude-usage.js'); + expect(getClaudeUsageExhaustion().exhausted).toBe(false); + }); + + it('returns exhausted=true with the earliest recovery when all accounts are exhausted', async () => { + const reset0 = new Date('2026-04-28T01:00:00Z').toISOString(); + const reset1 = new Date('2026-04-28T03:00:00Z').toISOString(); + getAllTokensMock.mockReturnValue([ + { + token: 'sk-ant-oat01-aaaaaaaa', + index: 0, + masked: 'aaa', + isActive: true, + isRateLimited: false, + }, + { + token: 'sk-ant-oat01-bbbbbbbb', + index: 1, + masked: 'bbb', + isActive: false, + isRateLimited: false, + }, + ]); + readJsonFileMock.mockReturnValue({ + 'account-0': { + usage: { + five_hour: { utilization: 96, resets_at: reset0 }, + seven_day: { utilization: 50, resets_at: '' }, + }, + fetchedAt: Date.now(), + }, + 'account-1': { + usage: { + five_hour: { utilization: 30, resets_at: '' }, + seven_day: { utilization: 99, resets_at: reset1 }, + }, + fetchedAt: Date.now(), + }, + }); + const { getClaudeUsageExhaustion } = await import('./claude-usage.js'); + const info = getClaudeUsageExhaustion(); + expect(info.exhausted).toBe(true); + // Earliest recovery is account-0 (5h reset at reset0). + expect(info.nextResetAt).toBe(reset0); + }); + + it('returns exhausted=false when at least one account still has headroom', async () => { + getAllTokensMock.mockReturnValue([ + { + token: 'sk-ant-oat01-aaaaaaaa', + index: 0, + masked: 'aaa', + isActive: true, + isRateLimited: false, + }, + { + token: 'sk-ant-oat01-bbbbbbbb', + index: 1, + masked: 'bbb', + isActive: false, + isRateLimited: false, + }, + ]); + readJsonFileMock.mockReturnValue({ + 'account-0': { + usage: { + five_hour: { utilization: 99 }, + seven_day: { utilization: 99 }, + }, + fetchedAt: Date.now(), + }, + 'account-1': { + usage: { + five_hour: { utilization: 80 }, + seven_day: { utilization: 70 }, + }, + fetchedAt: Date.now(), + }, + }); + const { getClaudeUsageExhaustion } = await import('./claude-usage.js'); + expect(getClaudeUsageExhaustion().exhausted).toBe(false); + }); + + it('treats rate-limited accounts as exhausted', async () => { + getAllTokensMock.mockReturnValue([ + { + token: 'sk-ant-oat01-aaaaaaaa', + index: 0, + masked: 'aaa', + isActive: false, + isRateLimited: true, + }, + ]); + readJsonFileMock.mockReturnValue({}); + const { getClaudeUsageExhaustion } = await import('./claude-usage.js'); + expect(getClaudeUsageExhaustion().exhausted).toBe(true); + }); + + it('handles utilization on the 0–1 scale (legacy fractional form)', async () => { + getAllTokensMock.mockReturnValue([ + { + token: 'sk-ant-oat01-aaaaaaaa', + index: 0, + masked: 'aaa', + isActive: true, + isRateLimited: false, + }, + ]); + readJsonFileMock.mockReturnValue({ + 'account-0': { + usage: { + five_hour: { utilization: 0.97 }, + seven_day: { utilization: 0.4 }, + }, + fetchedAt: Date.now(), + }, + }); + const { getClaudeUsageExhaustion } = await import('./claude-usage.js'); + expect(getClaudeUsageExhaustion().exhausted).toBe(true); + }); + + it('treats percent-scale utilization=1 as 1%, not 100%', async () => { + getAllTokensMock.mockReturnValue([ + { + token: 'sk-ant-oat01-aaaaaaaa', + index: 0, + masked: 'aaa', + isActive: true, + isRateLimited: false, + }, + ]); + readJsonFileMock.mockReturnValue({ + 'account-0': { + usage: { + five_hour: { utilization: 1 }, + seven_day: { utilization: 40 }, + }, + fetchedAt: Date.now(), + }, + }); + const { getClaudeUsageExhaustion } = await import('./claude-usage.js'); + expect(getClaudeUsageExhaustion().exhausted).toBe(false); + }); +}); + +describe('getCodexUsageExhaustion', () => { + it('returns exhausted=false when no Codex accounts are configured', async () => { + getAllCodexAccountsMock.mockReturnValue([]); + const { getCodexUsageExhaustion } = + await import('./codex-usage-collector.js'); + expect(getCodexUsageExhaustion()).toEqual({ + exhausted: false, + nextResetAt: null, + }); + }); + + it('returns exhausted=false when an account has unknown usage (-1)', async () => { + getAllCodexAccountsMock.mockReturnValue([ + { + index: 0, + accountId: 'a', + planType: 'pro', + isActive: true, + isRateLimited: false, + cachedUsagePct: -1, + cachedUsageD7Pct: -1, + }, + ]); + const { getCodexUsageExhaustion } = + await import('./codex-usage-collector.js'); + expect(getCodexUsageExhaustion().exhausted).toBe(false); + }); + + it('returns exhausted=true with earliest recovery when all accounts are exhausted', async () => { + const reset0 = new Date('2026-04-28T02:00:00Z').toISOString(); + const reset1 = new Date('2026-04-28T05:00:00Z').toISOString(); + getAllCodexAccountsMock.mockReturnValue([ + { + index: 0, + accountId: 'a', + planType: 'pro', + isActive: true, + isRateLimited: false, + cachedUsagePct: 96, + cachedUsageD7Pct: 40, + resetAt: reset0, + }, + { + index: 1, + accountId: 'b', + planType: 'pro', + isActive: false, + isRateLimited: false, + cachedUsagePct: 30, + cachedUsageD7Pct: 99, + resetD7At: reset1, + }, + ]); + const { getCodexUsageExhaustion } = + await import('./codex-usage-collector.js'); + const info = getCodexUsageExhaustion(); + expect(info.exhausted).toBe(true); + expect(info.nextResetAt).toBe(reset0); + }); + + it('returns exhausted=false when at least one Codex account has headroom', async () => { + getAllCodexAccountsMock.mockReturnValue([ + { + index: 0, + accountId: 'a', + planType: 'pro', + isActive: true, + isRateLimited: false, + cachedUsagePct: 99, + cachedUsageD7Pct: 99, + }, + { + index: 1, + accountId: 'b', + planType: 'pro', + isActive: false, + isRateLimited: false, + cachedUsagePct: 50, + cachedUsageD7Pct: 80, + }, + ]); + const { getCodexUsageExhaustion } = + await import('./codex-usage-collector.js'); + expect(getCodexUsageExhaustion().exhausted).toBe(false); + }); + + it('treats rate-limited Codex accounts as exhausted', async () => { + getAllCodexAccountsMock.mockReturnValue([ + { + index: 0, + accountId: 'a', + planType: 'pro', + isActive: false, + isRateLimited: true, + cachedUsagePct: 10, + cachedUsageD7Pct: 10, + }, + ]); + const { getCodexUsageExhaustion } = + await import('./codex-usage-collector.js'); + expect(getCodexUsageExhaustion().exhausted).toBe(true); + }); +}); diff --git a/src/usage-primer.ts b/src/usage-primer.ts new file mode 100644 index 0000000..490ab5c --- /dev/null +++ b/src/usage-primer.ts @@ -0,0 +1,188 @@ +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; + +import { CODEX_WARMUP_CONFIG } from './config.js'; +import { runCodexWarmupCycle } from './codex-warmup.js'; +import { logger } from './logger.js'; +import { getAllTokens } from './token-rotation.js'; + +/** + * Usage-window alignment primer. + * + * Anthropic/OpenAI enforce 5h rolling usage windows that start on first + * inference call. We anchor windows to fixed KST times by firing a tiny + * primer call at 08, 13, 18, 23 KST (4 windows × 5h = 20h, leaving a + * 04:00–08:00 gap blocked by message-runtime-gating). + * + * Daily reset point ends up being 08:00 KST (start of first slot after gap). + */ + +const PRIMER_HOURS_KST = [8, 13, 18, 23]; +const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +const CLAUDE_PRIMER_TIMEOUT_MS = 60_000; + +function resolveClaudeBinary(): string { + const candidates = [ + path.resolve( + process.cwd(), + 'runners/agent-runner/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64/claude', + ), + path.resolve( + process.cwd(), + 'runners/agent-runner/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl/claude', + ), + ]; + for (const p of candidates) { + if (fs.existsSync(p)) return p; + } + return candidates[0]; +} + +function runClaudePrimerOnce( + token: string, +): Promise<{ ok: boolean; reason: string }> { + const binary = resolveClaudeBinary(); + if (!fs.existsSync(binary)) { + return Promise.resolve({ ok: false, reason: 'binary_missing' }); + } + const args = [ + '-p', + 'ok', + '--model', + 'haiku', + '--output-format', + 'text', + '--max-turns', + '1', + '--dangerously-skip-permissions', + ]; + + return new Promise((resolve) => { + let done = false; + let stderr = ''; + const finish = (result: { ok: boolean; reason: string }): void => { + if (done) return; + done = true; + clearTimeout(timer); + resolve(result); + }; + const timer = setTimeout(() => { + try { + proc.kill(); + } catch { + /* ignore */ + } + finish({ ok: false, reason: 'timeout' }); + }, CLAUDE_PRIMER_TIMEOUT_MS); + + const proc = spawn(binary, args, { + stdio: ['ignore', 'pipe', 'pipe'], + env: { + ...process.env, + CLAUDE_CODE_OAUTH_TOKEN: token, + }, + }); + proc.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + if (stderr.length > 2000) stderr = stderr.slice(-2000); + }); + proc.on('error', (err) => { + logger.warn({ err }, 'Claude primer spawn error'); + finish({ ok: false, reason: 'spawn_error' }); + }); + proc.on('close', (code) => { + if (code === 0) { + finish({ ok: true, reason: 'ok' }); + } else { + logger.warn( + { exitCode: code, stderr: stderr.trim().slice(-500) || undefined }, + 'Claude primer command failed', + ); + finish({ ok: false, reason: `exit_${code ?? 'unknown'}` }); + } + }); + }); +} + +async function runClaudePrimerCycle(): Promise { + const tokens = getAllTokens(); + const candidates = tokens.filter((t) => !t.isRateLimited); + if (candidates.length === 0) { + logger.info('Claude primer skipped: no eligible tokens'); + return; + } + const target = candidates[0]; + logger.info( + { tokenIndex: target.index }, + 'Starting Claude primer (haiku, prompt=ok)', + ); + const result = await runClaudePrimerOnce(target.token); + if (result.ok) { + logger.info({ tokenIndex: target.index }, 'Claude primer completed'); + } else { + logger.warn( + { tokenIndex: target.index, reason: result.reason }, + 'Claude primer failed', + ); + } +} + +async function runCodexPrimerCycle(): Promise { + try { + const result = await runCodexWarmupCycle(CODEX_WARMUP_CONFIG); + logger.info({ result }, 'Codex primer cycle finished'); + } catch (err) { + logger.warn({ err }, 'Codex primer cycle threw'); + } +} + +async function runPrimerCycle(): Promise { + logger.info('Running scheduled usage primer cycle'); + await Promise.allSettled([runClaudePrimerCycle(), runCodexPrimerCycle()]); +} + +export function msUntilNextPrimerSlotKST(nowMs: number = Date.now()): number { + // KST = UTC+9 (no DST). Shift so KST clock can be read via getUTC*. + const kstNowShifted = nowMs + KST_OFFSET_MS; + const kstDate = new Date(kstNowShifted); + const kstH = kstDate.getUTCHours(); + + let nextH = PRIMER_HOURS_KST.find((h) => h > kstH); + let dayOffset = 0; + if (nextH === undefined) { + nextH = PRIMER_HOURS_KST[0]; + dayOffset = 1; + } + + const target = new Date(kstNowShifted); + target.setUTCHours(nextH, 0, 0, 0); + if (dayOffset === 1) target.setUTCDate(target.getUTCDate() + 1); + + return target.getTime() - kstNowShifted; +} + +let primerTimer: ReturnType | null = null; + +export function startUsagePrimer(): void { + if (primerTimer) return; + const scheduleNext = (): void => { + const delay = msUntilNextPrimerSlotKST(); + primerTimer = setTimeout(() => { + void runPrimerCycle().finally(scheduleNext); + }, delay); + const fireAt = new Date(Date.now() + delay).toISOString(); + logger.info( + { delayMinutes: Math.round(delay / 60_000), fireAt }, + 'Next usage primer scheduled', + ); + }; + scheduleNext(); +} + +export function stopUsagePrimer(): void { + if (primerTimer) { + clearTimeout(primerTimer); + primerTimer = null; + } +} diff --git a/status.sh b/status.sh new file mode 100644 index 0000000..b6f9a6c --- /dev/null +++ b/status.sh @@ -0,0 +1 @@ +systemctl --user status ejclaw