diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4ff4403 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +APP_SLACK_APP_TOKEN=xapp-... +APP_SLACK_BOT_TOKEN=xoxb-... +MISHU_REPO=/path/to/target/repo + +# Optional: +# MISHU_AGENT=codex +# MISHU_SANDBOX_TEMPLATE=myregistry/image:tag +# MISHU_LOG_LEVEL=summary +# MISHU_BOT_USER=U... diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..773830f --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +# Enforce the single gate — keep the repo green at every commit. +# Runs Biome (lint+format+organize-imports) + tsc --noEmit + Vitest — all offline. +# In a genuine pinch, bypass with `git commit --no-verify`. +exec npm run check diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..9d5857b --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,16 @@ +name: Audit + +on: + schedule: + - cron: "0 6 * * 1" # weekly, Mondays 06:00 UTC + workflow_dispatch: + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: npm audit --omit=dev diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4258c8c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,19 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci --ignore-scripts + - run: npm run check + - run: npm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ffb14d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +*.log +.env +.env.* +!.env.example +.DS_Store +coverage/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..532dc3d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,76 @@ +# AGENTS.md + +Rules for contributors and coding agents working in this repo. + +Mishu is a Slack-to-coding-agent router. A Slack thread maps to one `sbx` sandbox and one coding-agent +session. The router is plumbing, not an LLM. + +## Build And Test + +- Run `npm install` once. +- Run `npm run check` before every commit. It runs Biome, `tsc --noEmit`, and Vitest. +- Use `npm run check:fix` for formatter/import fixes. +- Use `npm test`, `npm run test:watch`, or `npm run test:cov` for Vitest. +- Use `npm run build` to emit `dist/`. +- Use `npm run test:live` only for live `sbx` checks; it is not part of CI. + +The pre-commit hook in `.githooks/pre-commit` runs `npm run check`. + +## Module Rules + +- ESM only. Relative imports include `.js`. +- Use `import type` for type-only imports. +- TypeScript is strict, with `noUncheckedIndexedAccess` and unused checks enabled. +- New pure logic should have colocated `*.test.ts` coverage. + +## Architecture Invariants + +``` +Slack <-> PlatformAdapter <-> Router <-> SandboxProvider <-> sandbox + \-> CodingBackend +``` + +- The router does not call models, inspect intent, or decide whether to open PRs. +- Agent output is an opaque byte stream until it reaches `CodingBackend`. +- Codex-specific parsing lives in `src/backend/codex.ts`. +- Claude-specific parsing lives in `src/backend/claude.ts`. +- The router has no durable database. Per-thread state lives inside the sandbox under + `~/.agent-state/`. +- Router memory may hold only transient locks, dedupe state, and pending/running state. +- Keep pure code separate from I/O shells. Pure modules should not import `node:child_process`, + `node:fs`, or Slack SDK packages. + +## Router Lifecycle + +- ACK Socket Mode envelopes immediately and handle work asynchronously. +- Drop Slack retries with `retry_num > 0`. +- Use reactions for acknowledgements: `eyes` while running, then `white_check_mark` or `x`. +- Do not reply just to acknowledge a mention; replies affect thread high-water marks. +- Coalesce mentions that arrive during a running turn into one follow-up turn. +- Keep stream readers attached until the agent process exits. +- Persist a new session id before posting the agent reply. +- Append transcript messages only after the reply posts successfully. + +## Sandbox Rules + +- Sandbox names come from `src/router/sandbox-name.ts`. +- Names must contain only `[A-Za-z0-9-]`; encode Slack `thread_ts` dots as `-`. +- Long names may hash, but `~/.agent-state/thread` must preserve the full thread id. +- Use `sbx create --clone` so agents work in an isolated VM, not the host tree. +- `sbx exec` commands are wrapped in `bash -c`. +- Do not use `--ephemeral`; resume depends on sandbox state. +- Agent stdin is redirected from `/dev/null` inside the VM to avoid headless hangs. +- Provisioning must create a writable repo clone, set `origin`, and start from a non-base branch. + +## Logging + +- Boundary logs go through `src/router/log.ts`. +- Summary logging is always on; verbose logging may include raw stdout/stderr and full prompts. +- Logging is best-effort and must not block or fail a turn. +- Do not log credentials or credential-shaped fields. + +## Commits + +- Keep commits small and logical. +- Keep `npm run check` green at every commit. +- Include the project `Co-Authored-By` trailer when making commits. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..de0e244 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See [AGENTS.md](./AGENTS.md) for the rules and architecture invariants to follow when working in this repo. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ee3d376 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Mishu contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 7b5d644..67ec34a 100644 --- a/README.md +++ b/README.md @@ -1 +1,147 @@ -# mishu +# Mishu + +![Mishu workflow: summon a coding agent in Slack, run it in an sbx sandbox, and continue in the same thread.](./assets/mishu.png) + +Mishu is a Slack bot that lets a team run coding-agent work from a shared thread. +Mention the bot, and it starts a coding-agent CLI inside an isolated `sbx` sandbox, then replies in +the same thread. Follow-up mentions in that thread resume the same sandbox and agent session. + +The router is deliberately small: it does not call an LLM, classify intent, or decide whether to +delegate. It moves Slack thread text to a coding agent, captures the result, and keeps each Slack +thread mapped to one sandbox. + +## Features + +- One Slack thread maps to one sandbox and one agent session. +- Follow-up mentions resume the same coding-agent context. +- Different threads can run in parallel. +- Sandboxes keep per-thread state under `~/.agent-state/`. +- The coding agent can push or open a PR when the sandbox has GitHub credentials. +- Boundary logs are written as JSONL for debugging and audits. + +## Requirements + +- Node.js 22+ +- The Docker Sandboxes `sbx` CLI +- A Slack app with Socket Mode enabled +- A target git repo +- A Codex or Claude Code credential configured in `sbx` + +For PR creation, the target repo should have a pushable `origin`, and the sandbox needs a repo-scoped +GitHub credential: + +```bash +sbx secret set -g github +``` + +## Setup + +### 1. Install dependencies + +```bash +npm install +``` + +### 2. Configure the coding-agent credential + +Codex is the default agent: + +```bash +npm run setup +``` + +To set up Claude Code instead: + +```bash +MISHU_AGENT=claude npm run setup +``` + +The setup command checks `sbx secret ls` and runs the needed interactive login flow. + +### 3. Create the Slack app + +Create an app at , enable Socket Mode, and add these scopes: + +| Token | Scopes | +| --- | --- | +| App-level token | `connections:write` | +| Bot token | `app_mentions:read`, `chat:write`, `reactions:write`, `channels:history`, `groups:history` | + +Subscribe the app to the `app_mention` bot event, install it to your workspace, and invite it to the +channels where it should work. + +Create `.env` from [.env.example](./.env.example): + +```bash +APP_SLACK_APP_TOKEN=xapp-... +APP_SLACK_BOT_TOKEN=xoxb-... +``` + +### 4. Run Mishu + +```bash +npm run build +MISHU_REPO=/path/to/your/repo \ + node --env-file=.env dist/cli/index.js --sandbox=sbx ./data +``` + +When Mishu prints `listening`, mention the bot in Slack. + +## Configuration + +| Variable | Required | Description | +| --- | --- | --- | +| `APP_SLACK_APP_TOKEN` | Yes | Slack app-level Socket Mode token (`xapp-...`). | +| `APP_SLACK_BOT_TOKEN` | Yes | Slack bot user token (`xoxb-...`). | +| `MISHU_REPO` | Yes | Repo path or ref passed to `sbx create --clone`. | +| `MISHU_AGENT` | No | `codex` or `claude`; default is `codex`. | +| `MISHU_SANDBOX_TEMPLATE` | No | Optional `sbx create --template` image. The image must include `bash`, `git`, and the selected agent CLI. | +| `MISHU_LOG_LEVEL` | No | `summary` or `verbose`; default is `summary`. | +| `MISHU_BOT_USER` | No | Slack bot user id; resolved with `auth.test` when omitted. | + +## Sandbox Governance & Security + +Mishu does not manage sandbox egress or network policy in code. When using `sbx` as the sandbox +provider, configure Docker Sandboxes governance and local policy outside Mishu: +. + +If `MISHU_SANDBOX_TEMPLATE` is set, Mishu passes it to `sbx create --template` for future sandbox +creation. Custom templates must include `bash`, `git`, and the selected agent CLI. + +## Logs + +Each turn is logged to `./data/router.log` as JSONL. Summary logs include argv, prompt size/hash, exit +code, duration, and final-message snippet. Verbose logs also include raw stdout/stderr chunks. + +```bash +tail -f ./data/router.log | jq 'select(.threadId=="t--")' +``` + +Credentials are stored and injected by `sbx`; Mishu does not put Slack, OpenAI, Anthropic, or GitHub +secrets in prompts or argv. + +## Development + +Read [AGENTS.md](./AGENTS.md) before changing the router, sandbox, platform, or backend seams. + +| Command | Description | +| --- | --- | +| `npm run check` | Biome check, TypeScript, and Vitest. This is the main gate. | +| `npm run check:fix` | Apply Biome fixes, then run the gate. | +| `npm test` | Run offline unit tests. | +| `npm run test:live` | Run live `sbx` tests. Requires the `sbx` daemon and is not part of CI. | +| `npm run build` | Compile `dist/`. | +| `npm run dev` | Run the CLI under `tsx watch`. | + +## Architecture + +``` +Slack <-> PlatformAdapter <-> Router <-> SandboxProvider <-> sandbox + \-> CodingBackend +``` + +- `src/platform/`: Slack event mapping and Slack SDK adapter. +- `src/router/`: thread state machine, dedupe, sandbox naming, state store, logging, dispatcher, idle sweep. +- `src/sandbox/`: `sbx` argv builders and provider. +- `src/backend/`: Codex and Claude Code argument builders and output parsers. +- `src/cli/`: argument parsing, setup flow, and composition root. diff --git a/assets/mishu.png b/assets/mishu.png new file mode 100644 index 0000000..76c8ee1 Binary files /dev/null and b/assets/mishu.png differ diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..edd9dae --- /dev/null +++ b/biome.json @@ -0,0 +1,39 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": true, + "includes": ["**", "!**/dist"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "all" + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..d02be85 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2734 @@ +{ + "name": "mishu", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mishu", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@slack/socket-mode": "^2.0.7", + "@slack/web-api": "^7.16.0" + }, + "bin": { + "mishu": "dist/cli/index.js" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.0", + "@types/node": "^22.10.0", + "@vitest/coverage-v8": "^4.1.7", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^4.1.7" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "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/@biomejs/biome": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.16.tgz", + "integrity": "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.16", + "@biomejs/cli-darwin-x64": "2.4.16", + "@biomejs/cli-linux-arm64": "2.4.16", + "@biomejs/cli-linux-arm64-musl": "2.4.16", + "@biomejs/cli-linux-x64": "2.4.16", + "@biomejs/cli-linux-x64-musl": "2.4.16", + "@biomejs/cli-win32-arm64": "2.4.16", + "@biomejs/cli-win32-x64": "2.4.16" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.16.tgz", + "integrity": "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.16.tgz", + "integrity": "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.16.tgz", + "integrity": "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.16.tgz", + "integrity": "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.16.tgz", + "integrity": "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.16.tgz", + "integrity": "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.16.tgz", + "integrity": "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.16", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.16.tgz", + "integrity": "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "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/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/@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.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "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.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "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.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "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.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "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.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "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.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "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.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "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.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "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.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "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.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@slack/logger": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-4.0.1.tgz", + "integrity": "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ==", + "license": "MIT", + "dependencies": { + "@types/node": ">=18" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/socket-mode": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-2.0.7.tgz", + "integrity": "sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/web-api": "^7.15.0", + "@types/node": ">=18", + "@types/ws": "^8", + "eventemitter3": "^5", + "ws": "^8" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.0" + } + }, + "node_modules/@slack/types": { + "version": "2.21.1", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.21.1.tgz", + "integrity": "sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0", + "npm": ">= 6.12.0" + } + }, + "node_modules/@slack/web-api": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.16.0.tgz", + "integrity": "sha512-68SAV77uuGKuhyyaRytX8UijVnqSLsTSKslGXw17cjQYXn+jtNl7gbaEjHgC5x2rhCuFdahBrEC2VCLppbzReg==", + "license": "MIT", + "dependencies": { + "@slack/logger": "^4.0.1", + "@slack/types": "^2.21.0", + "@types/node": ">=18", + "@types/retry": "0.12.0", + "axios": "^1.16.0", + "eventemitter3": "^5.0.1", + "form-data": "^4.0.4", + "is-electron": "2.2.2", + "is-stream": "^2", + "p-queue": "^6", + "p-retry": "^4", + "retry": "^0.13.1" + }, + "engines": { + "node": ">= 18", + "npm": ">= 8.6.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.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "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/@vitest/coverage-v8": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.7", + "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.7", + "vitest": "4.1.7" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz", + "integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz", + "integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.7", + "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.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz", + "integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.7", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz", + "integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "@vitest/utils": "4.1.7", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz", + "integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.3.tgz", + "integrity": "sha512-jCMQ6ZylLPudp0CDfBmQBZUsrh1/8psbmu9ibeVWKuHWD0YrH9YABwlKu5kVEFoT0GCQQW9Z/SxfuEbbkGQCRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "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/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/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "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/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/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "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/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/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.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, + "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/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "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/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/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/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/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/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/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/is-electron": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz", + "integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/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/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, + "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, + "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, + "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, + "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/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.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@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/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "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.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/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/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue/node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "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/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rolldown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/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/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.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "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.18", + "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.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz", + "integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.7", + "@vitest/mocker": "4.1.7", + "@vitest/pretty-format": "4.1.7", + "@vitest/runner": "4.1.7", + "@vitest/snapshot": "4.1.7", + "@vitest/spy": "4.1.7", + "@vitest/utils": "4.1.7", + "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.7", + "@vitest/browser-preview": "4.1.7", + "@vitest/browser-webdriverio": "4.1.7", + "@vitest/coverage-istanbul": "4.1.7", + "@vitest/coverage-v8": "4.1.7", + "@vitest/ui": "4.1.7", + "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/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/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d740da2 --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "name": "mishu", + "version": "0.0.1", + "type": "module", + "description": "Mishu — a coding agent that lives in your team's chat (Slack today): mention it in a thread and it does the work in an isolated sandbox, then replies in-thread.", + "license": "MIT", + "files": [ + "dist", + "assets", + ".env.example", + "README.md", + "LICENSE" + ], + "bin": { + "mishu": "dist/cli/index.js" + }, + "engines": { + "node": ">=22" + }, + "scripts": { + "prepare": "git config core.hooksPath .githooks 2>/dev/null || true", + "build": "tsc -p tsconfig.build.json", + "setup": "tsx src/cli/setup.ts", + "dev": "tsx watch src/cli/index.ts", + "start": "node dist/cli/index.js", + "typecheck": "tsc --noEmit", + "lint": "biome lint .", + "format": "biome format --write .", + "test": "vitest run", + "test:watch": "vitest", + "test:cov": "vitest run --coverage", + "test:live": "SBX_LIVE=1 vitest run --config vitest.live.config.ts", + "check": "biome check --error-on-warnings . && tsc --noEmit && vitest run", + "check:fix": "biome check --write --error-on-warnings . && tsc --noEmit && vitest run" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.0", + "@types/node": "^22.10.0", + "@vitest/coverage-v8": "^4.1.7", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^4.1.7" + }, + "dependencies": { + "@slack/socket-mode": "^2.0.7", + "@slack/web-api": "^7.16.0" + } +} diff --git a/src/backend/claude.test.ts b/src/backend/claude.test.ts new file mode 100644 index 0000000..83410e1 --- /dev/null +++ b/src/backend/claude.test.ts @@ -0,0 +1,177 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { ExecResult, SandboxHandle } from "../sandbox/index.js"; +import { + CLAUDE_NONBLOCKING_FLAGS, + ClaudeBackend, + claudeTurnArgs, + parseClaudeResult, + parseClaudeSessionFilename, + parseClaudeSessionId, +} from "./claude.js"; +import type { SandboxShellExecutor } from "./index.js"; + +const fixture = (name: string): string => + readFileSync(fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)), "utf8"); + +const stream = fixture("claude-stream.jsonl"); +const errorStream = fixture("claude-error.jsonl"); +const emptyStream = fixture("claude-empty.stdout.txt"); +const findOutput = fixture("claude-find-output.txt"); + +const SESSION_ID = "c4beb659-a473-49be-a56c-51ddc0c0ce81"; + +describe("claudeTurnArgs", () => { + it("turn 1: -p stream-json + --verbose + non-blocking flags + `--` guard, no --resume", () => { + const argv = claudeTurnArgs("fix the bug"); + expect(argv).toEqual([ + "claude", + "-p", + "--output-format", + "stream-json", + "--verbose", + ...CLAUDE_NONBLOCKING_FLAGS, + "--", + "fix the bug", + ]); + expect(argv).not.toContain("--resume"); + }); + + it("requires --verbose with stream-json and skips permission prompts headlessly", () => { + const argv = claudeTurnArgs("x"); + expect(argv).toContain("--verbose"); + expect(argv.join(" ")).toContain("--output-format stream-json"); + expect(argv).toContain("--dangerously-skip-permissions"); + }); + + it("follow-up: --resume -- with the same flags", () => { + expect(claudeTurnArgs("more", "sess-abc")).toEqual([ + "claude", + "-p", + "--output-format", + "stream-json", + "--verbose", + ...CLAUDE_NONBLOCKING_FLAGS, + "--resume", + "sess-abc", + "--", + "more", + ]); + }); + + it("treats null/undefined sessionId as a fresh turn", () => { + expect(claudeTurnArgs("x", null)).not.toContain("--resume"); + expect(claudeTurnArgs("x", undefined)).not.toContain("--resume"); + }); +}); + +describe("parseClaudeResult", () => { + it("extracts the final result text and marks ok (real success fixture)", () => { + // Real capture: init → rate_limit_event → assistant thinking → tool_use → + // tool_result → assistant text → result. The result event is authoritative. + expect(parseClaudeResult(stream)).toEqual({ finalText: "done", ok: true }); + }); + + it("flags empty output as a failed turn (headless empty-output regression)", () => { + expect(parseClaudeResult(emptyStream)).toEqual({ finalText: "", ok: false }); + expect(parseClaudeResult("")).toEqual({ finalText: "", ok: false }); + }); + + it("surfaces the claude error message on a failed turn (real fixture)", () => { + // Real capture: an is_error:true result whose subtype is nonetheless "success" + // (so we key on is_error, never subtype). The result text is relayed. + const result = parseClaudeResult(errorStream); + expect(result.ok).toBe(false); + expect(result.finalText).toContain("Not logged in"); // relayed to the user + }); + + it("tolerates interleaved non-JSON progress lines", () => { + const noisy = `warming up...\n${stream}`; + expect(parseClaudeResult(noisy).ok).toBe(true); + }); + + it("falls back to the last assistant text when no result event is present", () => { + const aborted = + '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"partial answer"}]},"session_id":"x"}'; + expect(parseClaudeResult(aborted)).toEqual({ finalText: "partial answer", ok: true }); + }); + + it("synthesizes an error from the subtype when an errored result carries no text", () => { + const maxTurns = + '{"type":"result","subtype":"error_max_turns","is_error":true,"session_id":"x"}'; + expect(parseClaudeResult(maxTurns)).toEqual({ finalText: "error_max_turns", ok: false }); + }); +}); + +describe("parseClaudeSessionId", () => { + it("reads the session id from the init line of the stream", () => { + expect(parseClaudeSessionId(stream)).toBe(SESSION_ID); + }); + + it("finds the id even on an errored turn (session still created)", () => { + expect(parseClaudeSessionId(errorStream)).toBe("baa1bd5f-51eb-4ee1-8b52-4d2872998728"); + }); + + it("returns null when no id is present", () => { + expect(parseClaudeSessionId('{"type":"assistant","message":{"content":[]}}')).toBeNull(); + expect(parseClaudeSessionId("")).toBeNull(); + }); +}); + +describe("parseClaudeSessionFilename", () => { + it("extracts the UUID from a transcript filename", () => { + expect(parseClaudeSessionFilename(fixture("claude-session-filename.txt").trim())).toBe( + SESSION_ID, + ); + }); + + it("returns null for a non-session filename", () => { + expect(parseClaudeSessionFilename("config.json")).toBeNull(); + expect(parseClaudeSessionFilename("notes.jsonl")).toBeNull(); + }); +}); + +describe("ClaudeBackend.captureSessionId (I/O via injected executor)", () => { + const handle: SandboxHandle = { name: "t-C0-1.2" }; + + const fakeExecutor = (stdout: string): SandboxShellExecutor & { lastCommand: string } => { + const calls = { lastCommand: "" }; + return { + get lastCommand() { + return calls.lastCommand; + }, + async execShell(_handle: SandboxHandle, command: string): Promise { + calls.lastCommand = command; + return { stdout, stderr: "", exitCode: 0 }; + }, + }; + }; + + it("finds the newest transcript and parses its id; $HOME stays unquoted", async () => { + const exec = fakeExecutor(findOutput); + const backend = new ClaudeBackend(exec); + expect(await backend.captureSessionId(handle)).toBe(SESSION_ID); + expect(exec.lastCommand).toContain("$HOME/.claude/projects"); + expect(exec.lastCommand).toContain("*.jsonl"); + }); + + it("throws when no transcript file exists", async () => { + const backend = new ClaudeBackend(fakeExecutor("")); + await expect(backend.captureSessionId(handle)).rejects.toThrow(/no session transcript/); + }); + + it("throws when the newest transcript filename has no session id", async () => { + const backend = new ClaudeBackend( + fakeExecutor("1780000000.0\t/home/agent/.claude/bad.jsonl\n"), + ); + await expect(backend.captureSessionId(handle)).rejects.toThrow(/could not parse/); + }); + + it("exposes the pure helpers through the interface", () => { + const backend = new ClaudeBackend(fakeExecutor("")); + expect(backend.configHome()).toBe("~/.claude"); + expect(backend.parseSessionId(stream)).toBe(SESSION_ID); + expect(backend.parseResult(emptyStream).ok).toBe(false); + expect(backend.events(stream)).toHaveLength(7); + }); +}); diff --git a/src/backend/claude.ts b/src/backend/claude.ts new file mode 100644 index 0000000..4bfb394 --- /dev/null +++ b/src/backend/claude.ts @@ -0,0 +1,146 @@ +import type { SandboxHandle } from "../sandbox/index.js"; +import type { CodingBackend, SandboxShellExecutor, TurnResult } from "./index.js"; +import { asString, isRecord, newestByMtime, parseJsonlEvents } from "./parsing.js"; + +export const CLAUDE_HOME = "~/.claude"; +export const CLAUDE_PROJECTS_PATH = "$HOME/.claude/projects"; + +export const CLAUDE_NONBLOCKING_FLAGS = ["--dangerously-skip-permissions"]; + +export function claudeTurnArgs(message: string, sessionId?: string | null): string[] { + const common = ["-p", "--output-format", "stream-json", "--verbose", ...CLAUDE_NONBLOCKING_FLAGS]; + if (sessionId === undefined || sessionId === null) { + return ["claude", ...common, "--", message]; + } + return ["claude", ...common, "--resume", sessionId, "--", message]; +} + +function extractAssistantText(event: Record): string | null { + if (asString(event.type) !== "assistant") { + return null; + } + const message = isRecord(event.message) ? event.message : null; + if (message === null || !Array.isArray(message.content)) { + return null; + } + let text = ""; + for (const block of message.content) { + if (isRecord(block) && asString(block.type) === "text") { + text += asString(block.text) ?? ""; + } + } + return text === "" ? null : text; +} + +export function parseClaudeResult(captured: string): TurnResult { + if (captured.trim() === "") { + return { finalText: "", ok: false }; + } + let finalText = ""; // authoritative: the result event's text + let assistantText = ""; // fallback: the last assistant message's text + let errorText = ""; // an is_error result's text (or its subtype) + for (const line of captured.split("\n")) { + if (line.trim() === "") { + continue; + } + let event: unknown; + try { + event = JSON.parse(line); + } catch { + continue; + } + if (!isRecord(event)) { + continue; + } + if (asString(event.type) === "result") { + const text = asString(event.result); + if (event.is_error === true) { + errorText = text ?? asString(event.subtype) ?? ""; + } else if (text !== null) { + finalText = text; + } + continue; + } + const text = extractAssistantText(event); + if (text !== null) { + assistantText = text; + } + } + if (finalText.trim() !== "") { + return { finalText, ok: true }; + } + if (errorText.trim() !== "") { + return { finalText: errorText, ok: false }; + } + if (assistantText.trim() !== "") { + return { finalText: assistantText, ok: true }; + } + return { finalText: "", ok: false }; +} + +export function parseClaudeSessionId(captured: string): string | null { + for (const line of captured.split("\n")) { + if (line.trim() === "") { + continue; + } + let event: unknown; + try { + event = JSON.parse(line); + } catch { + continue; + } + if (isRecord(event)) { + const id = asString(event.session_id); + if (id !== null) { + return id; + } + } + } + return null; +} + +const SESSION_UUID_RE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; + +export function parseClaudeSessionFilename(filename: string): string | null { + const match = SESSION_UUID_RE.exec(filename); + return match?.[1] ?? null; +} + +export class ClaudeBackend implements CodingBackend { + constructor(private readonly executor: SandboxShellExecutor) {} + + configHome(): string { + return CLAUDE_HOME; + } + + turnArgs(message: string, sessionId?: string): string[] { + return claudeTurnArgs(message, sessionId); + } + + parseResult(captured: string): TurnResult { + return parseClaudeResult(captured); + } + + parseSessionId(captured: string): string | null { + return parseClaudeSessionId(captured); + } + + events(captured: string): unknown[] { + return parseJsonlEvents(captured); + } + + async captureSessionId(handle: SandboxHandle): Promise { + const command = `find ${CLAUDE_PROJECTS_PATH} -name '*.jsonl' -printf '%T@\\t%p\\n' 2>/dev/null`; + const { stdout } = await this.executor.execShell(handle, command); + const newest = newestByMtime(stdout); + if (newest === null) { + throw new Error("claude: no session transcript found to capture the session id"); + } + const filename = newest.slice(newest.lastIndexOf("/") + 1); + const id = parseClaudeSessionFilename(filename); + if (id === null) { + throw new Error(`claude: could not parse the session id from transcript file "${filename}"`); + } + return id; + } +} diff --git a/src/backend/codex.test.ts b/src/backend/codex.test.ts new file mode 100644 index 0000000..8d9beb4 --- /dev/null +++ b/src/backend/codex.test.ts @@ -0,0 +1,169 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import type { ExecResult, SandboxHandle } from "../sandbox/index.js"; +import { + CODEX_NONBLOCKING_FLAGS, + CodexBackend, + codexTurnArgs, + parseCodexResult, + parseRolloutId, + parseSessionId, +} from "./codex.js"; +import type { SandboxShellExecutor } from "./index.js"; + +const fixture = (name: string): string => + readFileSync(fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)), "utf8"); + +const stream = fixture("codex-json-stream.jsonl"); +const errorStream = fixture("codex-error.jsonl"); +const emptyStream = fixture("codex-empty.stdout.txt"); +const findOutput = fixture("codex-find-output.txt"); + +describe("codexTurnArgs", () => { + it("turn 1: exec --json + non-blocking flags + `--` guard, no --ephemeral/-i", () => { + const argv = codexTurnArgs("fix the bug"); + expect(argv).toEqual([ + "codex", + "exec", + "--json", + "--skip-git-repo-check", + ...CODEX_NONBLOCKING_FLAGS, + "--", + "fix the bug", + ]); + expect(argv).not.toContain("--ephemeral"); + expect(argv).not.toContain("-i"); + }); + + it("keeps the native sandbox on and approvals non-blocking", () => { + const argv = codexTurnArgs("x"); + expect(argv.join(" ")).toContain("sandbox_mode=workspace-write"); + expect(argv.join(" ")).toContain("approval_policy=never"); + }); + + it("follow-up: exec resume with the same flags", () => { + expect(codexTurnArgs("more", "sess-abc")).toEqual([ + "codex", + "exec", + "resume", + "--json", + "--skip-git-repo-check", + ...CODEX_NONBLOCKING_FLAGS, + "--", + "sess-abc", + "more", + ]); + }); + + it("treats null/undefined sessionId as a fresh turn", () => { + expect(codexTurnArgs("x", null)).toContain("exec"); + expect(codexTurnArgs("x", null)).not.toContain("resume"); + }); +}); + +describe("parseCodexResult", () => { + it("extracts the final assistant message and marks ok (success fixture)", () => { + expect(parseCodexResult(stream)).toEqual({ + finalText: "Fixed the login timeout in auth.go and added a regression test.", + ok: true, + }); + }); + + it("flags empty output as a failed turn (headless regression)", () => { + expect(parseCodexResult(emptyStream)).toEqual({ finalText: "", ok: false }); + expect(parseCodexResult("")).toEqual({ finalText: "", ok: false }); + }); + + it("surfaces the codex error message on a failed turn (real fixture)", () => { + const result = parseCodexResult(errorStream); + expect(result.ok).toBe(false); + expect(result.finalText).toContain("usage limit"); // relayed to the user + }); + + it("tolerates interleaved non-JSON progress lines", () => { + const noisy = `progress: thinking...\n${stream}`; + expect(parseCodexResult(noisy).ok).toBe(true); + }); + + it("handles the older msg-wrapped agent_message shape", () => { + const old = '{"id":"1","msg":{"type":"agent_message","message":"done via msg shape"}}'; + expect(parseCodexResult(old)).toEqual({ finalText: "done via msg shape", ok: true }); + }); + + it("handles a task_complete last_agent_message", () => { + const done = '{"type":"turn.completed","last_agent_message":"shipped"}'; + expect(parseCodexResult(done)).toEqual({ finalText: "shipped", ok: true }); + }); +}); + +describe("parseSessionId", () => { + it("reads the session/thread id from the turn-1 stream", () => { + expect(parseSessionId(stream)).toBe("7f3e9c21-4b6a-4c2d-9e1f-2a3b4c5d6e7f"); + }); + + it("finds the id even on an errored turn (real thread.started, session still created)", () => { + expect(parseSessionId(errorStream)).toBe("019e800c-958e-79e0-b7c9-9a5502563f58"); + }); + + it("returns null when no id is present", () => { + expect( + parseSessionId('{"type":"item.completed","item":{"type":"assistant_message","text":"hi"}}'), + ).toBeNull(); + expect(parseSessionId("")).toBeNull(); + }); +}); + +describe("parseRolloutId", () => { + it("extracts the UUID from a rollout filename", () => { + expect(parseRolloutId(fixture("codex-rollout-filename.txt").trim())).toBe( + "7f3e9c21-4b6a-4c2d-9e1f-2a3b4c5d6e7f", + ); + }); + + it("returns null for a non-rollout filename", () => { + expect(parseRolloutId("config.toml")).toBeNull(); + }); +}); + +describe("CodexBackend.captureSessionId (I/O via injected executor)", () => { + const handle: SandboxHandle = { name: "t-C0-1.2" }; + + const fakeExecutor = (stdout: string): SandboxShellExecutor & { lastCommand: string } => { + const calls = { lastCommand: "" }; + return { + get lastCommand() { + return calls.lastCommand; + }, + async execShell(_handle: SandboxHandle, command: string): Promise { + calls.lastCommand = command; + return { stdout, stderr: "", exitCode: 0 }; + }, + }; + }; + + it("finds the newest rollout and parses its id; $HOME stays unquoted", async () => { + const exec = fakeExecutor(findOutput); + const backend = new CodexBackend(exec); + expect(await backend.captureSessionId(handle)).toBe("7f3e9c21-4b6a-4c2d-9e1f-2a3b4c5d6e7f"); + expect(exec.lastCommand).toContain("$HOME/.codex/sessions"); + expect(exec.lastCommand).toContain("rollout-*.jsonl"); + }); + + it("throws when no rollout file exists", async () => { + const backend = new CodexBackend(fakeExecutor("")); + await expect(backend.captureSessionId(handle)).rejects.toThrow(/no rollout file/); + }); + + it("throws when the newest rollout filename has no session id", async () => { + const backend = new CodexBackend(fakeExecutor("1780000000.0\t/home/agent/.codex/bad.jsonl\n")); + await expect(backend.captureSessionId(handle)).rejects.toThrow(/could not parse/); + }); + + it("exposes the pure helpers through the interface", () => { + const backend = new CodexBackend(fakeExecutor("")); + expect(backend.configHome()).toBe("~/.codex"); + expect(backend.parseSessionId(stream)).toBe("7f3e9c21-4b6a-4c2d-9e1f-2a3b4c5d6e7f"); + expect(backend.parseResult(emptyStream).ok).toBe(false); + expect(backend.events(stream)).toHaveLength(4); + }); +}); diff --git a/src/backend/codex.ts b/src/backend/codex.ts new file mode 100644 index 0000000..ebb800c --- /dev/null +++ b/src/backend/codex.ts @@ -0,0 +1,167 @@ +import type { SandboxHandle } from "../sandbox/index.js"; +import type { CodingBackend, SandboxShellExecutor, TurnResult } from "./index.js"; +import { asString, isRecord, newestByMtime, parseJsonlEvents } from "./parsing.js"; + +export const CODEX_HOME = "~/.codex"; +export const CODEX_SESSIONS_PATH = "$HOME/.codex/sessions"; + +export const CODEX_NONBLOCKING_FLAGS = [ + "-c", + "sandbox_mode=workspace-write", + "-c", + "approval_policy=never", +]; + +export function codexTurnArgs(message: string, sessionId?: string | null): string[] { + const common = ["--json", "--skip-git-repo-check", ...CODEX_NONBLOCKING_FLAGS]; + if (sessionId === undefined || sessionId === null) { + return ["codex", "exec", ...common, "--", message]; + } + return ["codex", "exec", "resume", ...common, "--", sessionId, message]; +} + +function extractAssistantText(event: Record): string | null { + const last = + asString(event.last_agent_message) ?? + (isRecord(event.msg) ? asString(event.msg.last_agent_message) : null); + if (last !== null) { + return last; + } + const item = isRecord(event.item) ? event.item : null; + if (item !== null) { + const itemType = asString(item.type) ?? asString(item.item_type); + if (itemType === "assistant_message" || itemType === "agent_message") { + return asString(item.text) ?? asString(item.message); + } + } + const msg = isRecord(event.msg) ? event.msg : null; + if (msg !== null && asString(msg.type) === "agent_message") { + return asString(msg.message) ?? asString(msg.text); + } + const type = asString(event.type); + if (type === "assistant_message" || type === "agent_message") { + return asString(event.text) ?? asString(event.message); + } + return null; +} + +function extractErrorMessage(event: Record): string | null { + const type = asString(event.type) ?? ""; + if ( + type !== "error" && + type !== "turn.failed" && + !type.includes("error") && + !type.includes("failed") + ) { + return null; + } + return asString(event.message) ?? (isRecord(event.error) ? asString(event.error.message) : null); +} + +export function parseCodexResult(captured: string): TurnResult { + if (captured.trim() === "") { + return { finalText: "", ok: false }; + } + let finalText = ""; + let errorText = ""; + for (const line of captured.split("\n")) { + if (line.trim() === "") { + continue; + } + let event: unknown; + try { + event = JSON.parse(line); + } catch { + continue; + } + if (!isRecord(event)) { + continue; + } + const text = extractAssistantText(event); + if (text !== null && text !== "") { + finalText = text; + } + const error = extractErrorMessage(event); + if (error !== null && error !== "") { + errorText = error; + } + } + if (finalText.trim() !== "") { + return { finalText, ok: true }; + } + return { finalText: errorText, ok: false }; +} + +function findSessionId(record: Record): string | null { + return ( + asString(record.thread_id) ?? asString(record.session_id) ?? asString(record.conversation_id) + ); +} + +export function parseSessionId(captured: string): string | null { + for (const line of captured.split("\n")) { + if (line.trim() === "") { + continue; + } + let event: unknown; + try { + event = JSON.parse(line); + } catch { + continue; + } + if (!isRecord(event)) { + continue; + } + const id = findSessionId(event) ?? (isRecord(event.msg) ? findSessionId(event.msg) : null); + if (id !== null) { + return id; + } + } + return null; +} + +const ROLLOUT_UUID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; + +export function parseRolloutId(filename: string): string | null { + const match = ROLLOUT_UUID_RE.exec(filename); + return match?.[1] ?? null; +} + +export class CodexBackend implements CodingBackend { + constructor(private readonly executor: SandboxShellExecutor) {} + + configHome(): string { + return CODEX_HOME; + } + + turnArgs(message: string, sessionId?: string): string[] { + return codexTurnArgs(message, sessionId); + } + + parseResult(captured: string): TurnResult { + return parseCodexResult(captured); + } + + parseSessionId(captured: string): string | null { + return parseSessionId(captured); + } + + events(captured: string): unknown[] { + return parseJsonlEvents(captured); + } + + async captureSessionId(handle: SandboxHandle): Promise { + const command = `find ${CODEX_SESSIONS_PATH} -name 'rollout-*.jsonl' -printf '%T@\\t%p\\n' 2>/dev/null`; + const { stdout } = await this.executor.execShell(handle, command); + const newest = newestByMtime(stdout); + if (newest === null) { + throw new Error("codex: no rollout file found to capture the session id"); + } + const filename = newest.slice(newest.lastIndexOf("/") + 1); + const id = parseRolloutId(filename); + if (id === null) { + throw new Error(`codex: could not parse the session id from rollout file "${filename}"`); + } + return id; + } +} diff --git a/src/backend/fixtures/README.md b/src/backend/fixtures/README.md new file mode 100644 index 0000000..66b49af --- /dev/null +++ b/src/backend/fixtures/README.md @@ -0,0 +1,63 @@ +# Backend fixtures + +Sample agent-CLI output bytes that the backend parsers are verified against. + +## Codex + +Sample `codex exec --json` bytes that `codex.ts` parsing is verified against. + +Captured live against codex 0.130.0 in an sbx microVM (2026-05-31). The real +`--json` event schema is `thread.started` (carries `thread_id` = the session id), +`turn.started`, `item.*`, `turn.completed`/`turn.failed`, and `error`. The parser +is tolerant of several shapes so it survives version drift. + +- `codex-error.jsonl` — **REAL bytes**: a `thread.started` + `turn.started` + + `error` + `turn.failed` stream (the run hit the subscription usage limit). + Confirms `parseSessionId` reads `thread_id` and the error message is relayed. +- `codex-json-stream.jsonl` — a **successful** fresh turn (assistant message). Still + a documented placeholder: the live success path was **quota-blocked**; + recapture with an OpenAI API key or after the limit resets. The parser already + handles the `item.completed`/`agent_message`/`last_agent_message` shapes. +- `codex-empty.stdout.txt` — the headless empty-output regression: 0 bytes, exit 0. +- `codex-rollout-filename.txt` — a sample `~/.codex/sessions/.../rollout-*.jsonl` filename. +- `codex-find-output.txt` — sample `find … -printf '%T@\t%p\n'` output for newest-rollout selection. + +## Claude Code + +Sample `claude -p --output-format stream-json --verbose` bytes that `claude.ts` parsing is verified +against. The stream is JSONL: a `{"type":"system","subtype":"init",…}` first line (carries +`session_id`), `{"type":"assistant",…}` / `{"type":"user",…}` turns, and a final +`{"type":"result",…}` (carries `result` + `is_error` + `session_id`). Session transcripts live at +`~/.claude/projects//.jsonl` (the filename is the session id). + +**Live-validated 2026-05-31** against Claude Code **2.1.141** in an sbx `claude` microVM (image +`docker/sandbox-templates:claude-code-docker`, sbx v0.31.1): + +- the argv `claude -p --output-format stream-json --verbose --dangerously-skip-permissions -- PROMPT` + parses (the `--` operand is the prompt; the run reaches the API), and `--output-format stream-json` + errors without `--verbose` (`"When using --print, --output-format=stream-json requires --verbose"`); +- `--resume` **reuses** the session id (a stable id across turns) unless `--fork-session` is passed — + the basis for capture-once + resume; +- `is_error:true` can ship with `subtype:"success"` — so `parseClaudeResult` keys on `is_error`, + never `subtype`; +- `captureSessionId`'s `find $HOME/.claude/projects -name '*.jsonl' -printf '%T@\t%p\n'` returns the + real transcript (`…//.jsonl`); `parseClaudeSessionFilename` reads the id from + that basename; +- **end-to-end**: a `--clone` sandbox with a provisioned repo ran turn 1 (created a file via the + `Write` tool, `is_error:false`, no approval prompt) then turn 2 `claude -p --resume ` recalled a + planted codeword from session history — the **same session id across both turns** (no fork). + +Fixtures (all REAL captured bytes unless noted): + +- `claude-stream.jsonl` — **REAL bytes**: a successful turn that created a file (init → + `rate_limit_event` → assistant `thinking` → assistant `tool_use` (Write) → `tool_result` → + assistant text → `result`, `is_error:false`, `result:"done"`). Exercises the thinking/tool_use skip + and the authoritative `result` text; captured with `--dangerously-skip-permissions` (the Write ran + with no approval prompt). +- `claude-error.jsonl` — **REAL bytes**: a fresh turn that reached the API and failed auth + (`is_error:true`, `result:"Not logged in · Please run /login"`, `subtype:"success"`). The session + is still created (the init line), so `parseClaudeSessionId` still works and the message is relayed. +- `claude-empty.stdout.txt` — the headless empty-output failure mode: 0 bytes (parity with codex). +- `claude-session-filename.txt` — a sample `.jsonl` transcript basename. +- `claude-find-output.txt` — sample `find … -printf '%T@\t%p\n'` output for newest-session selection + (real format/paths; two lines to exercise the mtime tiebreak). diff --git a/src/backend/fixtures/claude-empty.stdout.txt b/src/backend/fixtures/claude-empty.stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/fixtures/claude-error.jsonl b/src/backend/fixtures/claude-error.jsonl new file mode 100644 index 0000000..5b5a39a --- /dev/null +++ b/src/backend/fixtures/claude-error.jsonl @@ -0,0 +1,3 @@ +{"type":"system","subtype":"init","cwd":"/home/agent/workspace","session_id":"baa1bd5f-51eb-4ee1-8b52-4d2872998728","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","NotebookEdit","Read","ScheduleWakeup","Skill","TaskOutput","TaskStop","TodoWrite","ToolSearch","WebFetch","WebSearch","Write"],"mcp_servers":[],"model":"claude-opus-4-7[1m]","permissionMode":"bypassPermissions","slash_commands":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","claude-api","clear","compact","context","heapdump","init","review","security-review","usage","insights","goal","team-onboarding"],"apiKeySource":"none","claude_code_version":"2.1.141","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","claude-api"],"plugins":[],"analytics_disabled":false,"uuid":"98f961e8-6ed8-4565-af25-5df4e904a6f6","memory_paths":{"auto":"/home/agent/.claude/projects/-home-agent-workspace/memory/"},"fast_mode_state":"off"} +{"type":"assistant","message":{"id":"973a1fd7-b152-4b67-8b03-4c22f8e5f8b0","container":null,"model":"","role":"assistant","stop_details":null,"stop_reason":"stop_sequence","stop_sequence":"","type":"message","usage":{"input_tokens":0,"output_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":null,"cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":null,"iterations":null,"speed":null},"content":[{"type":"text","text":"Not logged in · Please run /login"}],"context_management":null},"parent_tool_use_id":null,"session_id":"baa1bd5f-51eb-4ee1-8b52-4d2872998728","uuid":"49bac3b6-2d0a-4362-8eaa-32c19d6a3ba1","error":"authentication_failed"} +{"type":"result","subtype":"success","is_error":true,"api_error_status":null,"duration_ms":46,"duration_api_ms":0,"num_turns":1,"result":"Not logged in · Please run /login","stop_reason":"stop_sequence","session_id":"baa1bd5f-51eb-4ee1-8b52-4d2872998728","total_cost_usd":0,"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":0,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[],"speed":"standard"},"modelUsage":{},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"2d8bd5de-01b8-4e2a-8561-038a62e521d4"} diff --git a/src/backend/fixtures/claude-find-output.txt b/src/backend/fixtures/claude-find-output.txt new file mode 100644 index 0000000..484834a --- /dev/null +++ b/src/backend/fixtures/claude-find-output.txt @@ -0,0 +1,2 @@ +1780000000.0000000000 /home/agent/.claude/projects/-home-agent-repo/00000000-0000-4000-8000-000000000000.jsonl +1780277999.9999999999 /home/agent/.claude/projects/-home-agent-repo/c4beb659-a473-49be-a56c-51ddc0c0ce81.jsonl diff --git a/src/backend/fixtures/claude-session-filename.txt b/src/backend/fixtures/claude-session-filename.txt new file mode 100644 index 0000000..df01ad9 --- /dev/null +++ b/src/backend/fixtures/claude-session-filename.txt @@ -0,0 +1 @@ +c4beb659-a473-49be-a56c-51ddc0c0ce81.jsonl diff --git a/src/backend/fixtures/claude-stream.jsonl b/src/backend/fixtures/claude-stream.jsonl new file mode 100644 index 0000000..1f1f74e --- /dev/null +++ b/src/backend/fixtures/claude-stream.jsonl @@ -0,0 +1,7 @@ +{"type":"system","subtype":"init","cwd":"/home/agent/repo","session_id":"c4beb659-a473-49be-a56c-51ddc0c0ce81","tools":["Task","AskUserQuestion","Bash","CronCreate","CronDelete","CronList","Edit","EnterPlanMode","EnterWorktree","ExitPlanMode","ExitWorktree","Glob","Grep","Monitor","NotebookEdit","PushNotification","Read","RemoteTrigger","ScheduleWakeup","Skill","TaskOutput","TaskStop","TodoWrite","ToolSearch","WebFetch","WebSearch","Write"],"mcp_servers":[],"model":"claude-sonnet-4-6","permissionMode":"bypassPermissions","slash_commands":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","claude-api","clear","compact","context","heapdump","init","review","security-review","usage","insights","goal","team-onboarding"],"apiKeySource":"none","claude_code_version":"2.1.141","output_style":"default","agents":["claude","Explore","general-purpose","Plan","statusline-setup"],"skills":["update-config","debug","simplify","batch","fewer-permission-prompts","loop","schedule","claude-api"],"plugins":[],"analytics_disabled":false,"uuid":"51be01ac-e12c-43af-8070-015b8c589a32","memory_paths":{"auto":"/home/agent/.claude/projects/-home-agent-repo/memory/"},"fast_mode_state":"off"} +{"type":"rate_limit_event","rate_limit_info":{"status":"allowed","resetsAt":1780291800,"rateLimitType":"five_hour","overageStatus":"rejected","overageDisabledReason":"org_level_disabled","isUsingOverage":false},"uuid":"b716668b-a6bb-462b-8eb2-9b47b2e22258","session_id":"c4beb659-a473-49be-a56c-51ddc0c0ce81"} +{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01G3SguQf9J4BAyRdkbKY4F7","type":"message","role":"assistant","content":[{"type":"thinking","thinking":"The user wants me to create a file named CLAUDE_E2E.md with exactly the line \"codeword MANGO\".","signature":"EqYCCmUIDhgCKkA1AIQA6Z0BRZJlz6j6IFl3KLzkKFbNSbvdhllriovWAbBbgzWMD3XQ7CcFUquXp/+jtg2c9LqzI1ig+KorqZAVMhFjbGF1ZGUtc29ubmV0LTQtNjgAQgh0aGlua2luZxIMUjsKvLq5EZdLnpgtGgzzW8Elcrf5CpRfEXciMH3jaghAm/VtrPSqF2NloCp1mwTm+fywYZzy26J+OouoYIljBRoOK+asHtQc4G47sSpvMWp8Eo7Frs2olrcwiDIfQbY9IB628YfmxkUTIqpGz0cL2jEOLZH/W8RDqZXEC+Y5M3ANgycVoQP39Npz+YO0IPW06+3ruIaP3gTYgMcV9VeWytHHGYOeG4XwOeoCJlYL7SFn7w28BbhxVeWQzVYIGAE="}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":5248,"cache_read_input_tokens":12964,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5248},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"c4beb659-a473-49be-a56c-51ddc0c0ce81","uuid":"9b231fa6-c3d8-41e2-8352-740b37c4369b"} +{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01G3SguQf9J4BAyRdkbKY4F7","type":"message","role":"assistant","content":[{"type":"tool_use","id":"toolu_01MD1VFmB29EJqEjkbqCPadT","name":"Write","input":{"file_path":"/home/agent/repo/CLAUDE_E2E.md","content":"codeword MANGO\n"},"caller":{"type":"direct"}}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":3,"cache_creation_input_tokens":5248,"cache_read_input_tokens":12964,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":5248},"output_tokens":8,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"c4beb659-a473-49be-a56c-51ddc0c0ce81","uuid":"29b33a17-dd98-42f5-853d-934fa0305f25"} +{"type":"user","message":{"role":"user","content":[{"tool_use_id":"toolu_01MD1VFmB29EJqEjkbqCPadT","type":"tool_result","content":"File created successfully at: /home/agent/repo/CLAUDE_E2E.md (file state is current in your context — no need to Read it back)"}]},"parent_tool_use_id":null,"session_id":"c4beb659-a473-49be-a56c-51ddc0c0ce81","uuid":"f97d8bce-a5e5-4be8-85df-d2ec7fe729c4","timestamp":"2026-06-01T02:19:43.458Z","tool_use_result":{"type":"create","filePath":"/home/agent/repo/CLAUDE_E2E.md","content":"codeword MANGO\n","structuredPatch":[],"originalFile":null,"userModified":false}} +{"type":"assistant","message":{"model":"claude-sonnet-4-6","id":"msg_01MLXj9gm46QkBjSreLbKAPr","type":"message","role":"assistant","content":[{"type":"text","text":"done"}],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":1,"cache_creation_input_tokens":183,"cache_read_input_tokens":18212,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":183},"output_tokens":1,"service_tier":"standard","inference_geo":"not_available"},"diagnostics":null,"context_management":null},"parent_tool_use_id":null,"session_id":"c4beb659-a473-49be-a56c-51ddc0c0ce81","uuid":"55d94e6f-bc75-4655-b5e6-a9cf7ba689e9"} +{"type":"result","subtype":"success","is_error":false,"api_error_status":null,"duration_ms":3614,"duration_api_ms":4939,"num_turns":2,"result":"done","stop_reason":"end_turn","session_id":"c4beb659-a473-49be-a56c-51ddc0c0ce81","total_cost_usd":0.03228605,"usage":{"input_tokens":4,"cache_creation_input_tokens":5431,"cache_read_input_tokens":31176,"output_tokens":138,"server_tool_use":{"web_search_requests":0,"web_fetch_requests":0},"service_tier":"standard","cache_creation":{"ephemeral_1h_input_tokens":5431,"ephemeral_5m_input_tokens":0},"inference_geo":"","iterations":[{"input_tokens":1,"output_tokens":4,"cache_read_input_tokens":18212,"cache_creation_input_tokens":183,"cache_creation":{"ephemeral_5m_input_tokens":0,"ephemeral_1h_input_tokens":183},"type":"message"}],"speed":"standard"},"modelUsage":{"claude-haiku-4-5-20251001":{"inputTokens":375,"outputTokens":22,"cacheReadInputTokens":0,"cacheCreationInputTokens":0,"webSearchRequests":0,"costUSD":0.00048499999999999997,"contextWindow":200000,"maxOutputTokens":32000},"claude-sonnet-4-6":{"inputTokens":4,"outputTokens":138,"cacheReadInputTokens":31176,"cacheCreationInputTokens":5431,"webSearchRequests":0,"costUSD":0.03180105,"contextWindow":200000,"maxOutputTokens":32000}},"permission_denials":[],"terminal_reason":"completed","fast_mode_state":"off","uuid":"4a60d92e-a857-4c72-98be-d75b88cd6830"} diff --git a/src/backend/fixtures/codex-empty.stdout.txt b/src/backend/fixtures/codex-empty.stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/backend/fixtures/codex-error.jsonl b/src/backend/fixtures/codex-error.jsonl new file mode 100644 index 0000000..c1533cf --- /dev/null +++ b/src/backend/fixtures/codex-error.jsonl @@ -0,0 +1,4 @@ +{"type":"thread.started","thread_id":"019e800c-958e-79e0-b7c9-9a5502563f58"} +{"type":"turn.started"} +{"type":"error","message":"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), or try again at Jun 2nd, 2026 11:49 AM."} +{"type":"turn.failed","error":{"message":"You've hit your usage limit. Upgrade to Plus to continue using Codex (https://chatgpt.com/explore/plus), or try again at Jun 2nd, 2026 11:49 AM."}} diff --git a/src/backend/fixtures/codex-find-output.txt b/src/backend/fixtures/codex-find-output.txt new file mode 100644 index 0000000..6de5aa9 --- /dev/null +++ b/src/backend/fixtures/codex-find-output.txt @@ -0,0 +1,2 @@ +1748600000.1234 /root/.codex/sessions/2026/05/30/rollout-2026-05-30T12-00-00-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl +1748600100.5678 /root/.codex/sessions/2026/05/30/rollout-2026-05-30T12-30-00-7f3e9c21-4b6a-4c2d-9e1f-2a3b4c5d6e7f.jsonl diff --git a/src/backend/fixtures/codex-json-stream.jsonl b/src/backend/fixtures/codex-json-stream.jsonl new file mode 100644 index 0000000..222221e --- /dev/null +++ b/src/backend/fixtures/codex-json-stream.jsonl @@ -0,0 +1,4 @@ +{"type":"thread.started","thread_id":"7f3e9c21-4b6a-4c2d-9e1f-2a3b4c5d6e7f"} +{"type":"item.started","item":{"type":"assistant_message"}} +{"type":"item.completed","item":{"type":"assistant_message","text":"Fixed the login timeout in auth.go and added a regression test."}} +{"type":"turn.completed","usage":{"input_tokens":1200,"output_tokens":64}} diff --git a/src/backend/fixtures/codex-rollout-filename.txt b/src/backend/fixtures/codex-rollout-filename.txt new file mode 100644 index 0000000..f033d49 --- /dev/null +++ b/src/backend/fixtures/codex-rollout-filename.txt @@ -0,0 +1 @@ +rollout-2026-05-30T12-34-56-7f3e9c21-4b6a-4c2d-9e1f-2a3b4c5d6e7f.jsonl diff --git a/src/backend/index.ts b/src/backend/index.ts new file mode 100644 index 0000000..ce3622f --- /dev/null +++ b/src/backend/index.ts @@ -0,0 +1,24 @@ +import type { ExecResult, SandboxHandle } from "../sandbox/index.js"; + +export interface TurnResult { + finalText: string; + ok: boolean; +} + +export interface CodingBackend { + configHome(): string; + + turnArgs(message: string, sessionId?: string): string[]; + + parseResult(captured: string): TurnResult; + + captureSessionId(handle: SandboxHandle): Promise; + + parseSessionId?(captured: string): string | null; + + events?(captured: string): unknown[]; +} + +export interface SandboxShellExecutor { + execShell(handle: SandboxHandle, command: string): Promise; +} diff --git a/src/backend/parsing.test.ts b/src/backend/parsing.test.ts new file mode 100644 index 0000000..699d456 --- /dev/null +++ b/src/backend/parsing.test.ts @@ -0,0 +1,43 @@ +import { asString, isRecord, newestByMtime, parseJsonlEvents } from "./parsing.js"; + +describe("isRecord", () => { + it("accepts non-null objects and rejects everything else", () => { + expect(isRecord({ a: 1 })).toBe(true); + expect(isRecord([])).toBe(true); // arrays are objects; callers guard array-ness separately + expect(isRecord(null)).toBe(false); + expect(isRecord("x")).toBe(false); + expect(isRecord(7)).toBe(false); + expect(isRecord(undefined)).toBe(false); + }); +}); + +describe("asString", () => { + it("returns strings as-is and null otherwise", () => { + expect(asString("hi")).toBe("hi"); + expect(asString("")).toBe(""); + expect(asString(3)).toBeNull(); + expect(asString(null)).toBeNull(); + expect(asString(undefined)).toBeNull(); + }); +}); + +describe("parseJsonlEvents", () => { + it("parses every JSON line and degrades gracefully on garbage", () => { + expect(parseJsonlEvents('{"a":1}\n{"b":2}\n')).toEqual([{ a: 1 }, { b: 2 }]); + expect(parseJsonlEvents("not json\n{bad}\n")).toEqual([]); + expect(parseJsonlEvents("")).toEqual([]); + }); +}); + +describe("newestByMtime", () => { + it("picks the path with the greatest mtime from find -printf output", () => { + const out = "1780000000.0\t/a/old.jsonl\n1780277999.9\t/a/new.jsonl\n"; + expect(newestByMtime(out)).toBe("/a/new.jsonl"); + }); + + it("ignores blank and tab-less lines and returns null when empty", () => { + expect(newestByMtime("")).toBeNull(); + expect(newestByMtime("no-tab-here\n")).toBeNull(); + expect(newestByMtime("\n\n")).toBeNull(); + }); +}); diff --git a/src/backend/parsing.ts b/src/backend/parsing.ts new file mode 100644 index 0000000..fb58b35 --- /dev/null +++ b/src/backend/parsing.ts @@ -0,0 +1,54 @@ +/** + * Shared parsing primitives for coding backends — the agent-AGNOSTIC scaffolding + * every backend needs to read its CLI's captured bytes. These carry NO agent + * format knowledge (no event schema, no rollout/transcript naming): that stays + * in each backend. A backend layers its agent-specific interpretation + * on top (which JSON fields mean what, which glob/regex names a session file). + */ + +/** True for any non-null object (the precondition for safely indexing parsed JSON). */ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** A value as a string, or null if it isn't one. */ +export function asString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +/** Parse a JSONL stream into raw events for log enrichment (skip non-JSON lines). */ +export function parseJsonlEvents(captured: string): unknown[] { + const events: unknown[] = []; + for (const line of captured.split("\n")) { + if (line.trim() === "") { + continue; + } + try { + events.push(JSON.parse(line)); + } catch { + // skip non-JSON progress lines + } + } + return events; +} + +/** From `find … -printf '%T@\t%p\n'` output, the path with the greatest mtime. */ +export function newestByMtime(findOutput: string): string | null { + let bestPath: string | null = null; + let bestMtime = Number.NEGATIVE_INFINITY; + for (const line of findOutput.split("\n")) { + if (line.trim() === "") { + continue; + } + const tab = line.indexOf("\t"); + if (tab < 0) { + continue; + } + const mtime = Number.parseFloat(line.slice(0, tab)); + if (Number.isFinite(mtime) && mtime > bestMtime) { + bestMtime = mtime; + bestPath = line.slice(tab + 1); + } + } + return bestPath; +} diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts new file mode 100644 index 0000000..07c3616 --- /dev/null +++ b/src/cli/args.test.ts @@ -0,0 +1,31 @@ +import { parseArgs, USAGE } from "./args.js"; + +describe("parseArgs", () => { + it("parses --sandbox= and a data-dir positional", () => { + expect(parseArgs(["--sandbox=sbx", "./data"])).toEqual({ sandbox: "sbx", dataDir: "./data" }); + }); + + it("is order-independent", () => { + expect(parseArgs(["./data", "--sandbox=sbx"])).toEqual({ sandbox: "sbx", dataDir: "./data" }); + }); + + it("returns null when the sandbox or data-dir is missing", () => { + expect(parseArgs(["./data"])).toBeNull(); + expect(parseArgs(["--sandbox=sbx"])).toBeNull(); + expect(parseArgs([])).toBeNull(); + }); + + it("returns null for extra positionals or unknown flags", () => { + expect(parseArgs(["--sandbox=sbx", "./data", "./other"])).toBeNull(); + expect(parseArgs(["--sandbox=sbx", "--verbose", "./data"])).toBeNull(); + }); + + it("returns null for -h / --help", () => { + expect(parseArgs(["-h"])).toBeNull(); + expect(parseArgs(["--sandbox=sbx", "./data", "--help"])).toBeNull(); + }); + + it("documents optional sandbox template env", () => { + expect(USAGE).toContain("MISHU_SANDBOX_TEMPLATE"); + }); +}); diff --git a/src/cli/args.ts b/src/cli/args.ts new file mode 100644 index 0000000..2d169f0 --- /dev/null +++ b/src/cli/args.ts @@ -0,0 +1,38 @@ +export interface Args { + sandbox: string; + dataDir: string; +} + +export function parseArgs(argv: string[]): Args | null { + let sandbox = ""; + const positional: string[] = []; + for (const arg of argv) { + if (arg.startsWith("--sandbox=")) { + sandbox = arg.slice("--sandbox=".length); + } else if (arg === "-h" || arg === "--help") { + return null; + } else if (arg.startsWith("-")) { + return null; + } else { + positional.push(arg); + } + } + if (sandbox === "" || positional.length !== 1) { + return null; + } + return { sandbox, dataDir: positional[0] as string }; +} + +export const USAGE = [ + "Usage: mishu --sandbox= ", + " e.g. mishu --sandbox=sbx ./data", + "", + "Environment:", + " APP_SLACK_APP_TOKEN xapp-… app-level token (Socket Mode)", + " APP_SLACK_BOT_TOKEN xoxb-… bot token", + " MISHU_REPO path to the target git repo (the --clone seed)", + " MISHU_AGENT codex | claude (default: codex)", + " MISHU_SANDBOX_TEMPLATE sbx template image (optional)", + " MISHU_LOG_LEVEL summary | verbose (default: summary)", + " MISHU_BOT_USER bot user id (optional; resolved via auth.test otherwise)", +].join("\n"); diff --git a/src/cli/env.test.ts b/src/cli/env.test.ts new file mode 100644 index 0000000..4135b3b --- /dev/null +++ b/src/cli/env.test.ts @@ -0,0 +1,43 @@ +import { createArgv } from "../sandbox/sbx-argv.js"; +import { createOptionsFromEnv, optionalEnv } from "./env.js"; + +describe("optionalEnv", () => { + it("normalizes unset and empty env values to undefined", () => { + expect(optionalEnv(undefined)).toBeUndefined(); + expect(optionalEnv("")).toBeUndefined(); + }); + + it("preserves non-empty env values", () => { + expect(optionalEnv("myregistry/image:tag")).toBe("myregistry/image:tag"); + }); +}); + +describe("createOptionsFromEnv", () => { + it("omits the sandbox template when unset", () => { + const opts = createOptionsFromEnv("codex", {}); + expect(opts).toEqual({ agent: "codex", template: undefined }); + expect(createArgv("box", "/repo", opts)).not.toContain("--template"); + }); + + it("omits the sandbox template when empty", () => { + const opts = createOptionsFromEnv("codex", { MISHU_SANDBOX_TEMPLATE: "" }); + expect(opts).toEqual({ agent: "codex", template: undefined }); + expect(createArgv("box", "/repo", opts)).not.toContain("--template"); + }); + + it("threads a non-empty sandbox template into create argv", () => { + const opts = createOptionsFromEnv("claude", { + MISHU_SANDBOX_TEMPLATE: "myregistry/image:tag", + }); + expect(createArgv("box", "/repo", opts)).toEqual([ + "create", + "--clone", + "--name", + "box", + "--template", + "myregistry/image:tag", + "claude", + "/repo", + ]); + }); +}); diff --git a/src/cli/env.ts b/src/cli/env.ts new file mode 100644 index 0000000..009c659 --- /dev/null +++ b/src/cli/env.ts @@ -0,0 +1,15 @@ +import type { CreateOptions } from "../sandbox/sbx-argv.js"; +import type { Agent } from "./onboarding.js"; + +type Env = Record; + +export function optionalEnv(value: string | undefined): string | undefined { + return value === undefined || value === "" ? undefined : value; +} + +export function createOptionsFromEnv(agent: Agent, env: Env): CreateOptions { + return { + agent, + template: optionalEnv(env.MISHU_SANDBOX_TEMPLATE), + }; +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..a6092b4 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,104 @@ +#!/usr/bin/env node +/** + * Composition root. Wires the three seams + Router behind the + * onboarding gate, then starts the Socket Mode ingress: + * + * mishu --sandbox=sbx ./data + * + * See args.ts USAGE for the environment. The router is plumbing — no LLM here. + */ +import { createWriteStream, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { ClaudeBackend } from "../backend/claude.js"; +import { CodexBackend } from "../backend/codex.js"; +import type { CodingBackend } from "../backend/index.js"; +import { SlackAdapter } from "../platform/slack.js"; +import { IdleSweeper } from "../router/idle-sweep.js"; +import { Dispatcher, Router } from "../router/index.js"; +import { createJsonlSink, type LogLevel, type LogSink } from "../router/log.js"; +import { SbxProvider } from "../sandbox/sbx-provider.js"; +import { type Args, parseArgs, USAGE } from "./args.js"; +import { createOptionsFromEnv } from "./env.js"; +import { type Agent, ensureOnboarded, parseAgent } from "./onboarding.js"; + +/** Boundary-log sink: the router's stdout + an append-only JSONL file. */ +function fileLogSink(dataDir: string): LogSink { + mkdirSync(dataDir, { recursive: true }); + const stream = createWriteStream(join(dataDir, "router.log"), { flags: "a" }); + return createJsonlSink((line) => { + process.stdout.write(line); + stream.write(line); + }); +} + +function requireEnv(name: string): string { + const value = process.env[name]; + if (value === undefined || value === "") { + console.error(`Missing required environment variable: ${name}`); + process.exit(1); + } + return value; +} + +async function run(args: Args): Promise { + if (args.sandbox !== "sbx") { + console.error(`Unsupported --sandbox=${args.sandbox} (v0 supports: sbx)`); + process.exit(1); + } + const agent: Agent = parseAgent(process.env.MISHU_AGENT) ?? "codex"; + const level: LogLevel = process.env.MISHU_LOG_LEVEL === "verbose" ? "verbose" : "summary"; + + const provider = new SbxProvider({ createOptions: createOptionsFromEnv(agent, process.env) }); + + // Onboarding gate: require the agent's credential before serving. + const onboarded = await ensureOnboarded(agent, { + secretLs: () => provider.secretLs(), + log: (message) => console.error(message), + }); + if (!onboarded) { + process.exit(1); + } + + const appToken = requireEnv("APP_SLACK_APP_TOKEN"); + const botToken = requireEnv("APP_SLACK_BOT_TOKEN"); + const repoRef = requireEnv("MISHU_REPO"); + + const logSink = fileLogSink(args.dataDir); + const platform = new SlackAdapter({ appToken, botToken, botUserId: process.env.MISHU_BOT_USER }); + const botUser = await platform.whoAmI(); + const backend: CodingBackend = + agent === "claude" ? new ClaudeBackend(provider) : new CodexBackend(provider); + const dispatcher = new Dispatcher({ + platform, + sandbox: provider, + backend, + repoRef, + logSink, + level, + botUser, + }); + const router = new Router(platform, dispatcher, { logSink }); + + // Idle eviction: sbx stop (lossless) sandboxes whose threads have gone quiet. + // Sweep hourly; the router never auto-`rm`s. + new IdleSweeper({ sandbox: provider, platform, logSink }).start(60 * 60 * 1000); + + router.start(); + await platform.start(); + console.error(`[mishu] listening — agent=${agent} repo=${repoRef} data=${args.dataDir}`); +} + +function main(): void { + const argv = process.argv.slice(2); + const args = parseArgs(argv); + if (args === null) { + console.log(USAGE); + process.exit(argv.includes("-h") || argv.includes("--help") ? 0 : 1); + } + run(args).catch((err: unknown) => { + console.error("[mishu] fatal:", err); + process.exit(1); + }); +} + +main(); diff --git a/src/cli/onboarding.test.ts b/src/cli/onboarding.test.ts new file mode 100644 index 0000000..1281048 --- /dev/null +++ b/src/cli/onboarding.test.ts @@ -0,0 +1,96 @@ +import { + CLAUDE_LOGIN_SANDBOX, + credentialService, + detectMissingCredential, + ensureOnboarded, + loginSandbox, + onboardingInstructions, + parseAgent, + setupCommand, +} from "./onboarding.js"; + +describe("credentialService", () => { + it("maps agents to their sbx secret service", () => { + expect(credentialService("codex")).toBe("openai"); + expect(credentialService("claude")).toBe("anthropic"); + }); +}); + +describe("parseAgent", () => { + it("accepts codex/claude and rejects anything else", () => { + expect(parseAgent("codex")).toBe("codex"); + expect(parseAgent("claude")).toBe("claude"); + expect(parseAgent("")).toBeNull(); + expect(parseAgent(undefined)).toBeNull(); + expect(parseAgent("gpt")).toBeNull(); + }); +}); + +describe("detectMissingCredential", () => { + it("treats the empty 'No secrets found' output as missing", () => { + const empty = "No secrets found. Run 'sbx secret set --help' to see available services."; + expect(detectMissingCredential(empty, "codex")).toBe(true); + expect(detectMissingCredential(empty, "claude")).toBe(true); + }); + + it("detects a configured credential for the agent's service", () => { + const ls = "openai (oauth configured)\n"; + expect(detectMissingCredential(ls, "codex")).toBe(false); + expect(detectMissingCredential(ls, "claude")).toBe(true); // anthropic still missing + }); +}); + +describe("onboardingInstructions", () => { + it("guides Codex through the OAuth path the user chose", () => { + const text = onboardingInstructions("codex"); + expect(text).toContain("npm run setup"); + expect(text).toContain("sbx secret set -g openai --oauth"); + }); + + it("guides Claude through the named login sandbox (/login)", () => { + const text = onboardingInstructions("claude"); + expect(text).toContain("npm run setup"); + expect(text).toContain(CLAUDE_LOGIN_SANDBOX); + expect(text).toContain("/login"); + }); +}); + +describe("setupCommand", () => { + it("codex: a single sbx OAuth command", () => { + expect(setupCommand("codex")).toEqual(["secret", "set", "-g", "openai", "--oauth"]); + }); + + it("claude: a named throwaway sandbox, not an (unsupported) anthropic --oauth", () => { + expect(setupCommand("claude")).toEqual(["run", "--name", CLAUDE_LOGIN_SANDBOX, "claude"]); + expect(setupCommand("claude")).not.toContain("--oauth"); + }); +}); + +describe("loginSandbox", () => { + it("names the throwaway sandbox for Claude, none for Codex", () => { + expect(loginSandbox("claude")).toBe(CLAUDE_LOGIN_SANDBOX); + expect(loginSandbox("codex")).toBeNull(); + }); +}); + +describe("ensureOnboarded", () => { + it("returns true and stays quiet when the credential is present", async () => { + const logs: string[] = []; + const ok = await ensureOnboarded("codex", { + secretLs: async () => "openai (oauth configured)", + log: (m) => logs.push(m), + }); + expect(ok).toBe(true); + expect(logs).toEqual([]); + }); + + it("returns false and prints setup instructions when missing", async () => { + const logs: string[] = []; + const ok = await ensureOnboarded("codex", { + secretLs: async () => "No secrets found.", + log: (m) => logs.push(m), + }); + expect(ok).toBe(false); + expect(logs[0]).toContain("sbx secret set -g openai --oauth"); + }); +}); diff --git a/src/cli/onboarding.ts b/src/cli/onboarding.ts new file mode 100644 index 0000000..1d7497d --- /dev/null +++ b/src/cli/onboarding.ts @@ -0,0 +1,101 @@ +/** + * Router-guided onboarding. The router gates first use on the + * coding agent's credential being present in sbx, walks the human through the + * one-time setup, and then runs unattended forever after. + * + * The parsing/decision logic is pure; the guided flow needs only a `secretLs` + * capability (so it's injectable in tests). + */ + +export type Agent = "codex" | "claude"; + +/** Parse an agent name (from an env var or a prompt answer); null if unrecognized. */ +export function parseAgent(value: string | null | undefined): Agent | null { + return value === "codex" || value === "claude" ? value : null; +} + +/** The sbx secret service that backs each agent's auth. */ +export function credentialService(agent: Agent): string { + return agent === "codex" ? "openai" : "anthropic"; +} + +/** The throwaway sandbox `npm run setup` creates for Claude's `/login`, then removes. */ +export const CLAUDE_LOGIN_SANDBOX = "mishu-login"; + +/** + * The interactive `sbx` command (argv after the `sbx` bin) that establishes the + * agent's credential — what `npm run setup` runs. Codex has a one-shot + * OAuth command; Claude has no `anthropic --oauth`, so it logs in inside a named + * throwaway sandbox (`sbx run --name mishu-login claude` → /login) and sbx + * captures the credential host-side; setup removes the sandbox afterward. + */ +export function setupCommand(agent: Agent): string[] { + return agent === "codex" + ? ["secret", "set", "-g", credentialService(agent), "--oauth"] + : ["run", "--name", CLAUDE_LOGIN_SANDBOX, "claude"]; +} + +/** The throwaway login sandbox `setupCommand` creates (to remove after), or null (Codex). */ +export function loginSandbox(agent: Agent): string | null { + return agent === "claude" ? CLAUDE_LOGIN_SANDBOX : null; +} + +/** True if `sbx secret ls` shows no credential for the agent's service. */ +export function detectMissingCredential(secretLsOutput: string, agent: Agent): boolean { + const service = credentialService(agent); + const present = new RegExp(`\\b${service}\\b`, "i").test(secretLsOutput); + return !present; +} + +/** One-time setup instructions for the human (the OAuth/browser step needs a person). */ +export function onboardingInstructions(agent: Agent): string { + const service = credentialService(agent); + if (agent === "codex") { + return [ + `No '${service}' credential is configured in sbx for Codex.`, + "", + " Easiest: npm run setup (walks you through it)", + "", + " …or do it manually:", + ` 1. Run: sbx secret set -g ${service} --oauth`, + " 2. Finish the browser sign-in.", + ` 3. Re-run once 'sbx secret ls' shows '${service} (oauth configured)'.`, + "", + "(The credential is host-side and proxy-injected — it never enters a sandbox)", + ].join("\n"); + } + return [ + `No '${service}' credential is configured in sbx for Claude.`, + "", + " Easiest: npm run setup (walks you through it)", + "", + " …or do it manually:", + ` 1. Run: sbx run --name ${CLAUDE_LOGIN_SANDBOX} claude`, + " 2. In the session type /login, finish the browser sign-in, then exit.", + ` 3. Re-run once 'sbx secret ls' shows '${service} (oauth configured)'.`, + ` 4. (optional) sbx rm --force ${CLAUDE_LOGIN_SANDBOX} # the login sandbox is throwaway`, + "", + "(The credential is host-side and proxy-injected — it never enters a sandbox.", + " There's no 'anthropic --oauth' — login happens inside the sandbox above.)", + ].join("\n"); +} + +export interface OnboardingDeps { + /** Runs `sbx secret ls` and returns its raw output. */ + secretLs: () => Promise; + /** Where to surface human-facing setup instructions (stderr in the CLI). */ + log: (message: string) => void; +} + +/** + * Returns true if the agent's credential is present (proceed), or false after + * printing setup instructions (the human must complete the one-time OAuth). + */ +export async function ensureOnboarded(agent: Agent, deps: OnboardingDeps): Promise { + const output = await deps.secretLs(); + if (detectMissingCredential(output, agent)) { + deps.log(onboardingInstructions(agent)); + return false; + } + return true; +} diff --git a/src/cli/setup.ts b/src/cli/setup.ts new file mode 100644 index 0000000..58e8a2a --- /dev/null +++ b/src/cli/setup.ts @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * `npm run setup` — one-command onboarding. Checks the selected + * agent's sbx credential and, if it's missing, runs the interactive auth flow + * (browser OAuth for Codex; `sbx run claude` → /login for Claude) with the + * terminal attached, then confirms. Idempotent: re-running once configured just + * reports ready. The pure decisions (which service, which command) live in + * onboarding.ts; this file is the thin I/O shell. + */ +import { spawnSync } from "node:child_process"; +import { createInterface } from "node:readline/promises"; +import { SbxProvider } from "../sandbox/sbx-provider.js"; +import { + type Agent, + credentialService, + detectMissingCredential, + loginSandbox, + parseAgent, + setupCommand, +} from "./onboarding.js"; + +const SBX_BIN = "sbx"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Is the agent's credential present? Polls (sbx may set it a beat after the flow exits). */ +async function credentialPresent( + provider: SbxProvider, + agent: Agent, + tries: number, +): Promise { + for (let attempt = 0; attempt < tries; attempt++) { + if (!detectMissingCredential(await provider.secretLs(), agent)) { + return true; + } + if (attempt < tries - 1) { + await sleep(2000); + } + } + return false; +} + +/** MISHU_AGENT if set; else prompt on a TTY; else default codex (non-interactive). */ +async function resolveAgent(): Promise { + const fromEnv = parseAgent(process.env.MISHU_AGENT); + if (fromEnv !== null) { + return fromEnv; + } + if (process.stdin.isTTY !== true) { + return "codex"; + } + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question("Which agent? [codex/claude] (default: codex): "); + return parseAgent(answer.trim().toLowerCase()) ?? "codex"; + } finally { + rl.close(); + } +} + +async function main(): Promise { + const agent = await resolveAgent(); + const service = credentialService(agent); + const provider = new SbxProvider({ createOptions: { agent } }); + + // Already set up? (idempotent) + if (await credentialPresent(provider, agent, 1)) { + console.log(`✓ '${service}' is already configured in sbx — Mishu is ready (agent=${agent}).`); + return; + } + + // The auth flow is interactive (browser sign-in / `/login`); don't launch it + // headlessly (it would hang waiting on a terminal that isn't there). + if (process.stdin.isTTY !== true) { + console.error( + `'${service}' isn't configured for ${agent}, and setup is interactive — ` + + "run 'npm run setup' directly in a terminal.", + ); + process.exit(1); + } + + // Run the interactive auth flow with the terminal attached. + console.log(`Setting up the '${service}' credential for ${agent}…\n`); + if (agent === "claude") { + console.log( + "A Claude session will open — type /login, finish the browser sign-in, then exit.\n", + ); + } + const argv = setupCommand(agent); + const result = spawnSync(SBX_BIN, argv, { stdio: "inherit" }); + if (result.error !== undefined) { + console.error(`\nCouldn't run '${SBX_BIN} ${argv.join(" ")}': ${result.error.message}`); + console.error("Is the sbx CLI installed and on your PATH?"); + process.exit(1); + } + + // Confirm. + if (!(await credentialPresent(provider, agent, 3))) { + console.error(`\n✗ '${service}' still isn't configured. Re-run 'npm run setup' to try again.`); + process.exit(1); + } + + // Tidy up the throwaway login sandbox (Claude only) — best-effort; never fail over it. + const login = loginSandbox(agent); + if (login !== null) { + try { + await provider.destroy({ name: login }); + } catch { + console.log(`(Leftover login sandbox '${login}' — remove it with: sbx rm --force ${login})`); + } + } + + console.log(`\n✓ '${service}' configured — Mishu is ready (agent=${agent}).`); + console.log( + "Start it with: MISHU_REPO=/path/to/repo node --env-file=.env dist/cli/index.js --sandbox=sbx ./data", + ); +} + +main().catch((err: unknown) => { + console.error("[mishu setup] fatal:", err instanceof Error ? err.message : String(err)); + process.exit(1); +}); diff --git a/src/platform/index.ts b/src/platform/index.ts new file mode 100644 index 0000000..e1d0c83 --- /dev/null +++ b/src/platform/index.ts @@ -0,0 +1,25 @@ +import type { Mention, ThreadId, ThreadMessage } from "../types.js"; + +/** + * PlatformAdapter — ingress/egress of human conversation. + * + * v0: Slack via Socket Mode. The implementation MUST acknowledge the Socket + * Mode envelope immediately (<3s) and invoke the handler asynchronously — it + * must never block the ACK on sandbox/turn work. Acks to the user + * are reactions, not replies. + * + * Later platforms (Discord, Teams, Linear comments) implement the same shape. + */ +export interface PlatformAdapter { + /** Register the mention handler. The adapter ACKs the transport, then calls this. */ + onMention(handler: (mention: Mention) => void): void; + + /** Whole thread (sinceTs omitted) or only messages after sinceTs — the delta. */ + fetchThread(thread: ThreadId, sinceTs?: string): Promise; + + postReply(thread: ThreadId, text: string): Promise; + uploadFile(thread: ThreadId, path: string): Promise; + + addReaction(channel: string, ts: string, emoji: string): Promise; + removeReaction(channel: string, ts: string, emoji: string): Promise; +} diff --git a/src/platform/slack-map.test.ts b/src/platform/slack-map.test.ts new file mode 100644 index 0000000..7227fd9 --- /dev/null +++ b/src/platform/slack-map.test.ts @@ -0,0 +1,81 @@ +import { + mentionFromEvent, + type SlackAppMentionEvent, + threadMessageFromSlack, + threadMessagesFromReplies, +} from "./slack-map.js"; + +const base: SlackAppMentionEvent = { + type: "app_mention", + user: "U1", + text: "<@UBOT> fix the bug", + ts: "1748600000.500000", + channel: "C0ABCDEF", +}; + +describe("mentionFromEvent", () => { + it("a root mention (no thread_ts) uses its own ts as the thread id", () => { + expect(mentionFromEvent(base)).toEqual({ + thread: { channel: "C0ABCDEF", threadTs: "1748600000.500000" }, + ts: "1748600000.500000", + user: "U1", + text: "<@UBOT> fix the bug", + }); + }); + + it("a reply mention uses the parent thread_ts", () => { + const m = mentionFromEvent({ ...base, thread_ts: "1748600000.100000" }); + expect(m?.thread.threadTs).toBe("1748600000.100000"); + expect(m?.ts).toBe("1748600000.500000"); + }); + + it("drops the bot's own post", () => { + expect(mentionFromEvent({ ...base, user: "UBOT" }, "UBOT")).toBeNull(); + }); + + it("drops messages from other bots (bot_id present)", () => { + expect(mentionFromEvent({ ...base, bot_id: "B123" })).toBeNull(); + }); + + it("drops events missing ts / channel / user", () => { + expect(mentionFromEvent({ ...base, ts: undefined })).toBeNull(); + expect(mentionFromEvent({ ...base, channel: undefined })).toBeNull(); + expect(mentionFromEvent({ ...base, user: undefined })).toBeNull(); + }); + + it("defaults missing text to empty", () => { + expect(mentionFromEvent({ ...base, text: undefined })?.text).toBe(""); + }); +}); + +describe("threadMessageFromSlack / threadMessagesFromReplies", () => { + it("maps ts/user/text", () => { + expect(threadMessageFromSlack({ ts: "1.1", user: "U1", text: "hi" })).toEqual({ + ts: "1.1", + user: "U1", + text: "hi", + }); + }); + + it("falls back to bot_id then 'unknown' for the author", () => { + expect(threadMessageFromSlack({ ts: "1.1", bot_id: "B1", text: "x" })?.user).toBe("B1"); + expect(threadMessageFromSlack({ ts: "1.1", text: "x" })?.user).toBe("unknown"); + }); + + it("returns null for a message without a ts", () => { + expect(threadMessageFromSlack({ text: "no ts" })).toBeNull(); + }); + + it("filters unmappable messages from a replies page", () => { + expect( + threadMessagesFromReplies([ + { ts: "1.1", user: "U1", text: "a" }, + { text: "dropped (no ts)" }, + { ts: "1.2", user: "U2", text: "b" }, + ]), + ).toEqual([ + { ts: "1.1", user: "U1", text: "a" }, + { ts: "1.2", user: "U2", text: "b" }, + ]); + }); +}); diff --git a/src/platform/slack-map.ts b/src/platform/slack-map.ts new file mode 100644 index 0000000..d97c33f --- /dev/null +++ b/src/platform/slack-map.ts @@ -0,0 +1,78 @@ +import type { Mention, ThreadMessage } from "../types.js"; + +/** + * Pure Slack-event mapping — the subtle bits that hold the bugs, + * isolated from the Socket Mode / Web API I/O so they're unit-testable: + * - a root mention uses its own `ts` as the thread id (it opens the thread); + * a reply uses the parent `thread_ts`; + * - the bot's own posts and other bots never trigger a turn. + */ + +/** The fields we read off a Slack `app_mention` event. */ +export interface SlackAppMentionEvent { + type?: string; + user?: string; + bot_id?: string; + text?: string; + ts?: string; + channel?: string; + thread_ts?: string; + subtype?: string; +} + +/** + * Normalize an `app_mention` into a Mention, or null if it shouldn't trigger a + * turn (missing fields, a bot's message, or the bot's own post). + */ +export function mentionFromEvent(event: SlackAppMentionEvent, botUserId?: string): Mention | null { + if (event.ts === undefined || event.channel === undefined || event.user === undefined) { + return null; + } + if (event.bot_id !== undefined) { + return null; // another bot — don't loop + } + if (botUserId !== undefined && event.user === botUserId) { + return null; // our own post + } + // A root mention has no thread_ts; its own ts opens the thread. + const threadTs = event.thread_ts ?? event.ts; + return { + thread: { channel: event.channel, threadTs }, + ts: event.ts, + user: event.user, + text: event.text ?? "", + }; +} + +/** The fields we read off a message in `conversations.replies`. */ +export interface SlackMessage { + ts?: string; + user?: string; + bot_id?: string; + text?: string; + subtype?: string; +} + +/** Map a Slack thread message to a ThreadMessage, or null if it has no ts. */ +export function threadMessageFromSlack(message: SlackMessage): ThreadMessage | null { + if (message.ts === undefined) { + return null; + } + return { + ts: message.ts, + user: message.user ?? message.bot_id ?? "unknown", + text: message.text ?? "", + }; +} + +/** Map a `conversations.replies` page to ThreadMessages, dropping unmappable ones. */ +export function threadMessagesFromReplies(messages: SlackMessage[]): ThreadMessage[] { + const result: ThreadMessage[] = []; + for (const message of messages) { + const mapped = threadMessageFromSlack(message); + if (mapped !== null) { + result.push(mapped); + } + } + return result; +} diff --git a/src/platform/slack.ts b/src/platform/slack.ts new file mode 100644 index 0000000..f1379ff --- /dev/null +++ b/src/platform/slack.ts @@ -0,0 +1,128 @@ +import { SocketModeClient } from "@slack/socket-mode"; +import { WebClient } from "@slack/web-api"; +import type { Mention, ThreadId, ThreadMessage } from "../types.js"; +import type { PlatformAdapter } from "./index.js"; +import { + mentionFromEvent, + type SlackAppMentionEvent, + type SlackMessage, + threadMessagesFromReplies, +} from "./slack-map.js"; + +export interface SlackAdapterConfig { + appToken: string; + botToken: string; + botUserId?: string; +} + +interface SocketModeEventArgs { + ack: () => Promise; + event: SlackAppMentionEvent; + retry_num?: number; +} + +function slackErrorCode(err: unknown): string | undefined { + if (typeof err !== "object" || err === null || !("data" in err)) { + return undefined; + } + const data = (err as { data?: unknown }).data; + if (typeof data !== "object" || data === null || !("error" in data)) { + return undefined; + } + const error = (data as { error?: unknown }).error; + return typeof error === "string" ? error : undefined; +} + +export class SlackAdapter implements PlatformAdapter { + private readonly socket: SocketModeClient; + private readonly web: WebClient; + private botUserId: string | undefined; + private handler: ((mention: Mention) => void) | null = null; + + constructor(config: SlackAdapterConfig) { + this.socket = new SocketModeClient({ appToken: config.appToken }); + this.web = new WebClient(config.botToken); + this.botUserId = config.botUserId; + } + + onMention(handler: (mention: Mention) => void): void { + this.handler = handler; + } + + async whoAmI(): Promise { + if (this.botUserId === undefined) { + const auth = await this.web.auth.test(); + this.botUserId = typeof auth.user_id === "string" ? auth.user_id : undefined; + } + return this.botUserId; + } + + async start(): Promise { + await this.whoAmI(); + this.socket.on("app_mention", (args: SocketModeEventArgs) => { + void args.ack().catch(() => {}); + if (typeof args.retry_num === "number" && args.retry_num > 0) { + return; + } + const mention = mentionFromEvent(args.event, this.botUserId); + if (mention !== null && this.handler !== null) { + this.handler(mention); + } + }); + await this.socket.start(); + } + + async fetchThread(thread: ThreadId, sinceTs?: string): Promise { + const messages: SlackMessage[] = []; + let cursor: string | undefined; + do { + const res = await this.web.conversations.replies({ + channel: thread.channel, + ts: thread.threadTs, + oldest: sinceTs, + inclusive: false, + limit: 200, + cursor, + }); + messages.push(...((res.messages ?? []) as SlackMessage[])); + cursor = res.response_metadata?.next_cursor || undefined; + } while (cursor !== undefined); + return threadMessagesFromReplies(messages); + } + + async postReply(thread: ThreadId, text: string): Promise { + await this.web.chat.postMessage({ + channel: thread.channel, + thread_ts: thread.threadTs, + text, + }); + } + + async uploadFile(thread: ThreadId, path: string): Promise { + await this.web.filesUploadV2({ + channel_id: thread.channel, + thread_ts: thread.threadTs, + file: path, + }); + } + + async addReaction(channel: string, ts: string, emoji: string): Promise { + try { + await this.web.reactions.add({ channel, timestamp: ts, name: emoji }); + } catch (err) { + if (slackErrorCode(err) !== "already_reacted") { + throw err; + } + } + } + + async removeReaction(channel: string, ts: string, emoji: string): Promise { + try { + await this.web.reactions.remove({ channel, timestamp: ts, name: emoji }); + } catch (err) { + if (slackErrorCode(err) !== "no_reaction") { + throw err; + } + } + } +} diff --git a/src/router/agent-state-store.test.ts b/src/router/agent-state-store.test.ts new file mode 100644 index 0000000..8ca33dc --- /dev/null +++ b/src/router/agent-state-store.test.ts @@ -0,0 +1,83 @@ +import type { SandboxHandle } from "../sandbox/index.js"; +import type { ThreadMessage } from "../types.js"; +import { AgentStateStore, type SandboxFsLike } from "./agent-state-store.js"; + +const handle: SandboxHandle = { name: "t-C0ABCDEF-1748600000.123456" }; +const msg = (ts: string, user: string, text: string): ThreadMessage => ({ ts, user, text }); + +class FakeSandboxFs implements SandboxFsLike { + readonly files = new Map(); + writes = 0; + constructor(private readonly home = "/home/agent") {} + async homeDir(): Promise { + return this.home; + } + async readFile(_h: SandboxHandle, absPath: string): Promise { + return this.files.get(absPath) ?? null; + } + async writeFile(_h: SandboxHandle, absPath: string, content: string): Promise { + this.writes += 1; + this.files.set(absPath, content); + } +} + +describe("AgentStateStore — transcript", () => { + it("reads an empty ledger as [] (turn-1 signal)", async () => { + const store = new AgentStateStore(new FakeSandboxFs(), handle); + expect(await store.readTranscript()).toEqual([]); + }); + + it("appends a delta and reads it back under $HOME/.agent-state", async () => { + const fs = new FakeSandboxFs(); + const store = new AgentStateStore(fs, handle); + const delta = [msg("1.1", "U1", "hello"), msg("1.2", "U2", "world")]; + await store.appendDelta(delta); + expect(await store.readTranscript()).toEqual(delta); + expect(fs.files.has("/home/agent/.agent-state/transcript.jsonl")).toBe(true); + }); + + it("appends incrementally, preserving prior lines (read-modify-write)", async () => { + const store = new AgentStateStore(new FakeSandboxFs(), handle); + await store.appendDelta([msg("1.1", "U1", "a")]); + await store.appendDelta([msg("1.2", "U2", "b")]); + expect(await store.readTranscript()).toEqual([msg("1.1", "U1", "a"), msg("1.2", "U2", "b")]); + }); + + it("does not write when the delta is empty (no spurious file)", async () => { + const fs = new FakeSandboxFs(); + await new AgentStateStore(fs, handle).appendDelta([]); + expect(fs.writes).toBe(0); + }); +}); + +describe("AgentStateStore — session id", () => { + it("is null until written, then round-trips (trimmed)", async () => { + const fs = new FakeSandboxFs(); + const store = new AgentStateStore(fs, handle); + expect(await store.readSessionId()).toBeNull(); + await store.writeSessionId("7f3e9c21-4b6a-4c2d-9e1f-2a3b4c5d6e7f"); + expect(await store.readSessionId()).toBe("7f3e9c21-4b6a-4c2d-9e1f-2a3b4c5d6e7f"); + // stored with a trailing newline at the canonical path + expect(fs.files.get("/home/agent/.agent-state/session")).toBe( + "7f3e9c21-4b6a-4c2d-9e1f-2a3b4c5d6e7f\n", + ); + }); +}); + +describe("AgentStateStore — thread reverse map", () => { + it("round-trips the original channel+thread_ts (hash-name fallback)", async () => { + const store = new AgentStateStore(new FakeSandboxFs(), handle); + expect(await store.readThread()).toBeNull(); + await store.writeThread("C0ABCDEF 1748600000.123456"); + expect(await store.readThread()).toBe("C0ABCDEF 1748600000.123456"); + }); +}); + +describe("AgentStateStore — home resolution", () => { + it("uses the sandbox's reported $HOME for absolute paths", async () => { + const fs = new FakeSandboxFs("/root"); + const store = new AgentStateStore(fs, handle); + await store.writeSessionId("abc"); + expect(fs.files.has("/root/.agent-state/session")).toBe(true); + }); +}); diff --git a/src/router/agent-state-store.ts b/src/router/agent-state-store.ts new file mode 100644 index 0000000..72531f2 --- /dev/null +++ b/src/router/agent-state-store.ts @@ -0,0 +1,83 @@ +import type { SandboxHandle } from "../sandbox/index.js"; +import type { ThreadMessage } from "../types.js"; +import { + parseTranscript, + SESSION_PATH, + serializeTranscript, + THREAD_PATH, + TRANSCRIPT_PATH, +} from "./agent-state.js"; + +/** + * I/O layer of the per-thread durable state. Reads/writes the tiny + * `~/.agent-state/` dir that lives inside each thread's sandbox — the router + * keeps no state of its own. A single writer per file is guaranteed by + * per-thread serialization. + * + * Depends on a narrow sandbox-fs capability (not the full provider) so it fakes + * trivially in tests; the real SbxProvider implements it via `sbx exec`/`cp`. + */ +export interface SandboxFsLike { + /** The sandbox user's `$HOME` (paths under it persist with the VM). */ + homeDir(handle: SandboxHandle): Promise; + /** File contents, or null if the file does not exist. */ + readFile(handle: SandboxHandle, absPath: string): Promise; + /** Write contents, creating parent directories as needed. */ + writeFile(handle: SandboxHandle, absPath: string, content: string): Promise; +} + +export class AgentStateStore { + constructor( + private readonly fs: SandboxFsLike, + private readonly handle: SandboxHandle, + ) {} + + private async abs(relPath: string): Promise { + return `${await this.fs.homeDir(this.handle)}/${relPath}`; + } + + /** The seen-message ledger (empty on turn 1). */ + async readTranscript(): Promise { + const text = await this.fs.readFile(this.handle, await this.abs(TRANSCRIPT_PATH)); + return text === null ? [] : parseTranscript(text); + } + + /** + * Append the delta after a successful turn (write-after-success) so an + * abandoned turn re-feeds cleanly. Read-modify-write of the whole file — the + * ledger is small and has a single writer. + */ + async appendDelta(delta: ThreadMessage[]): Promise { + if (delta.length === 0) { + return; + } + const existing = await this.readTranscript(); + await this.fs.writeFile( + this.handle, + await this.abs(TRANSCRIPT_PATH), + serializeTranscript([...existing, ...delta]), + ); + } + + /** The coding-agent session id captured on turn 1, or null if unset. */ + async readSessionId(): Promise { + const text = await this.fs.readFile(this.handle, await this.abs(SESSION_PATH)); + const trimmed = text?.trim() ?? ""; + return trimmed === "" ? null : trimmed; + } + + async writeSessionId(id: string): Promise { + await this.fs.writeFile(this.handle, await this.abs(SESSION_PATH), `${id}\n`); + } + + /** Original channel+thread_ts, for reverse lookup of a hash-named sandbox. */ + async readThread(): Promise { + const text = await this.fs.readFile(this.handle, await this.abs(THREAD_PATH)); + const trimmed = text?.trim() ?? ""; + return trimmed === "" ? null : trimmed; + } + + async writeThread(value: string): Promise { + await this.fs.writeFile(this.handle, await this.abs(THREAD_PATH), `${value}\n`); + } +} diff --git a/src/router/agent-state.test.ts b/src/router/agent-state.test.ts new file mode 100644 index 0000000..181aaef --- /dev/null +++ b/src/router/agent-state.test.ts @@ -0,0 +1,117 @@ +import type { ThreadMessage } from "../types.js"; +import { + computeDelta, + highWaterMark, + parseTranscript, + serializeTranscript, +} from "./agent-state.js"; + +const msg = (ts: string, user: string, text: string): ThreadMessage => ({ ts, user, text }); + +describe("highWaterMark", () => { + it("returns null for an empty ledger (turn-1 signal)", () => { + expect(highWaterMark([])).toBeNull(); + }); + + it("returns the largest ts regardless of input order", () => { + expect( + highWaterMark([ + msg("1748600000.500000", "U1", "b"), + msg("1748600000.100000", "U1", "a"), + msg("1748600000.900000", "U1", "c"), + ]), + ).toBe("1748600000.900000"); + }); + + it("compares Slack ts lexicographically (= chronologically)", () => { + // micro-level ordering + expect( + highWaterMark([msg("1748600000.000009", "U", "x"), msg("1748600000.000010", "U", "y")]), + ).toBe("1748600000.000010"); + // across the seconds boundary + expect( + highWaterMark([msg("1748600000.999999", "U", "x"), msg("1748600001.000000", "U", "y")]), + ).toBe("1748600001.000000"); + }); +}); + +describe("computeDelta", () => { + const fetched = [ + msg("1748600000.100000", "U1", "old request"), + msg("1748600000.200000", "BOT", "old bot reply"), + msg("1748600000.300000", "U2", "teammate note"), + msg("1748600000.400000", "U1", "@bot new request"), // the current trigger + ]; + + it("turn 1 (hwm null) returns the whole thread minus bot posts (primer)", () => { + const delta = computeDelta(fetched, null, { botUser: "BOT" }); + expect(delta.map((m) => m.text)).toEqual(["old request", "teammate note", "@bot new request"]); + }); + + it("follow-up excludes <= hwm and bot posts, but INCLUDES the current trigger", () => { + const delta = computeDelta(fetched, "1748600000.200000", { botUser: "BOT" }); + // strictly after hwm: teammate note + the new request; bot reply (if any) dropped + expect(delta.map((m) => m.text)).toEqual(["teammate note", "@bot new request"]); + }); + + it("treats ts === hwm as already seen (exclusive boundary)", () => { + const delta = computeDelta(fetched, "1748600000.300000", { botUser: "BOT" }); + expect(delta.map((m) => m.text)).toEqual(["@bot new request"]); + }); + + it("drops the bot's own later posts so the HWM never lands on a bot reply", () => { + const withBotReply = [ + msg("1748600000.400000", "U1", "request"), + msg("1748600000.500000", "BOT", "result reply"), + ]; + const delta = computeDelta(withBotReply, "1748600000.350000", { botUser: "BOT" }); + expect(delta.map((m) => m.text)).toEqual(["request"]); + }); + + it("without a botUser, keeps every message after the hwm", () => { + const delta = computeDelta(fetched, "1748600000.250000"); + expect(delta).toHaveLength(2); + }); +}); + +describe("serializeTranscript / parseTranscript", () => { + const messages = [ + msg("1748600000.100000", "U1", "hello"), + msg("1748600000.200000", "U2", 'has "quotes" and a\nnewline'), + ]; + + it("round-trips messages (incl. quotes and embedded newlines)", () => { + expect(parseTranscript(serializeTranscript(messages))).toEqual(messages); + }); + + it("serializes one JSON object per line with a trailing newline", () => { + const text = serializeTranscript(messages); + expect(text.endsWith("\n")).toBe(true); + expect(text.trimEnd().split("\n")).toHaveLength(2); + }); + + it("serializes only {ts, user, text}, dropping any extra fields", () => { + const dirty = [ + { ts: "1.1", user: "U", text: "hi", extra: "drop me" }, + ] as unknown as ThreadMessage[]; + expect(JSON.parse(serializeTranscript(dirty).trim())).toEqual({ + ts: "1.1", + user: "U", + text: "hi", + }); + }); + + it("returns '' for an empty ledger and [] when parsing it back", () => { + expect(serializeTranscript([])).toBe(""); + expect(parseTranscript("")).toEqual([]); + }); + + it("tolerates blank lines and a missing trailing newline", () => { + const text = '{"ts":"1.1","user":"U","text":"a"}\n\n{"ts":"1.2","user":"U","text":"b"}'; + expect(parseTranscript(text)).toEqual([msg("1.1", "U", "a"), msg("1.2", "U", "b")]); + }); + + it("throws on malformed message records", () => { + expect(() => parseTranscript('{"ts":"1.1","user":"U"}')).toThrow(/invalid transcript line 1/); + }); +}); diff --git a/src/router/agent-state.ts b/src/router/agent-state.ts new file mode 100644 index 0000000..ec320b7 --- /dev/null +++ b/src/router/agent-state.ts @@ -0,0 +1,72 @@ +import type { ThreadMessage } from "../types.js"; + +export const AGENT_STATE_DIR = ".agent-state"; +export const TRANSCRIPT_PATH = `${AGENT_STATE_DIR}/transcript.jsonl`; +export const SESSION_PATH = `${AGENT_STATE_DIR}/session`; +export const THREAD_PATH = `${AGENT_STATE_DIR}/thread`; + +export function highWaterMark(transcript: ThreadMessage[]): string | null { + let max: string | null = null; + for (const message of transcript) { + if (max === null || message.ts > max) { + max = message.ts; + } + } + return max; +} + +export interface DeltaOptions { + botUser?: string; +} + +export function computeDelta( + fetched: ThreadMessage[], + hwm: string | null, + opts: DeltaOptions = {}, +): ThreadMessage[] { + const { botUser } = opts; + return fetched.filter((message) => { + if (botUser !== undefined && message.user === botUser) { + return false; + } + if (hwm !== null && message.ts <= hwm) { + return false; + } + return true; + }); +} + +/** Serialize messages to JSONL (one `{ts, user, text}` per line, trailing \n). */ +export function serializeTranscript(messages: ThreadMessage[]): string { + if (messages.length === 0) { + return ""; + } + return `${messages + .map((m) => JSON.stringify({ ts: m.ts, user: m.user, text: m.text })) + .join("\n")}\n`; +} + +export function parseTranscript(text: string): ThreadMessage[] { + const messages: ThreadMessage[] = []; + for (const [index, line] of text.split("\n").entries()) { + if (line.trim() === "") { + continue; + } + const parsed: unknown = JSON.parse(line); + if (!isThreadMessage(parsed)) { + throw new Error(`invalid transcript line ${index + 1}`); + } + messages.push(parsed); + } + return messages; +} + +function isThreadMessage(value: unknown): value is ThreadMessage { + return ( + typeof value === "object" && + value !== null && + typeof (value as ThreadMessage).ts === "string" && + typeof (value as ThreadMessage).user === "string" && + typeof (value as ThreadMessage).text === "string" + ); +} diff --git a/src/router/dedupe.test.ts b/src/router/dedupe.test.ts new file mode 100644 index 0000000..986101d --- /dev/null +++ b/src/router/dedupe.test.ts @@ -0,0 +1,56 @@ +import { createTtlSet, DEFAULT_DEDUPE_TTL_MS } from "./dedupe.js"; + +describe("createTtlSet", () => { + it("reports added keys as present within the TTL window", () => { + const clock = 1000; + const set = createTtlSet(1000, () => clock); + expect(set.has("ts-1")).toBe(false); + set.add("ts-1"); + expect(set.has("ts-1")).toBe(true); + }); + + it("tracks distinct keys independently", () => { + let clock = 0; + const set = createTtlSet(1000, () => clock); + set.add("a"); + clock = 1; // keep the same window + expect(set.has("a")).toBe(true); + expect(set.has("b")).toBe(false); + set.add("b"); + expect(set.has("a")).toBe(true); + expect(set.has("b")).toBe(true); + }); + + it("does NOT evict before the TTL elapses", () => { + let clock = 0; + const set = createTtlSet(1000, () => clock); + set.add("a"); + clock = 999; // one tick before expiry + expect(set.has("a")).toBe(true); + }); + + it("evicts exactly at the TTL boundary", () => { + let clock = 0; + const set = createTtlSet(1000, () => clock); + set.add("a"); + clock = 1000; // current - inserted === ttl -> expired + expect(set.has("a")).toBe(false); + }); + + it("size() counts only non-expired keys", () => { + let clock = 0; + const set = createTtlSet(1000, () => clock); + set.add("a"); // inserted at 0 + clock = 500; + set.add("b"); // inserted at 500 + expect(set.size()).toBe(2); + clock = 1000; // "a" expires (1000-0>=1000), "b" survives (1000-500<1000) + expect(set.size()).toBe(1); + expect(set.has("a")).toBe(false); + expect(set.has("b")).toBe(true); + }); + + it("defaults the TTL to at least Slack's ~5-min retry window", () => { + expect(DEFAULT_DEDUPE_TTL_MS).toBeGreaterThanOrEqual(5 * 60_000); + }); +}); diff --git a/src/router/dedupe.ts b/src/router/dedupe.ts new file mode 100644 index 0000000..c80a12e --- /dev/null +++ b/src/router/dedupe.ts @@ -0,0 +1,39 @@ +/** Slack retries an unacked event for ~5 minutes (immediately, then ~1m, ~5m). */ +export const DEFAULT_DEDUPE_TTL_MS = 5 * 60_000; + +export interface TtlSet { + has(key: string): boolean; + add(key: string): void; + size(): number; +} + +export function createTtlSet( + ttlMs: number = DEFAULT_DEDUPE_TTL_MS, + now: () => number = Date.now, +): TtlSet { + const insertedAt = new Map(); + + const evict = (current: number): void => { + for (const [key, ts] of insertedAt) { + if (current - ts >= ttlMs) { + insertedAt.delete(key); + } + } + }; + + return { + has(key: string): boolean { + evict(now()); + return insertedAt.has(key); + }, + add(key: string): void { + const current = now(); + evict(current); + insertedAt.set(key, current); + }, + size(): number { + evict(now()); + return insertedAt.size; + }, + }; +} diff --git a/src/router/dispatcher.test.ts b/src/router/dispatcher.test.ts new file mode 100644 index 0000000..10ade36 --- /dev/null +++ b/src/router/dispatcher.test.ts @@ -0,0 +1,366 @@ +import type { CodingBackend, TurnResult } from "../backend/index.js"; +import type { ExecCallOptions, ExecResult, SandboxHandle } from "../sandbox/index.js"; +import type { Mention, ThreadId, ThreadMessage } from "../types.js"; +import { serializeTranscript } from "./agent-state.js"; +import { Dispatcher, formatPrompt } from "./dispatcher.js"; +import { createRecordingSink } from "./log.js"; + +const msg = (ts: string, user: string, text: string): ThreadMessage => ({ ts, user, text }); +const trigger: Mention = { + thread: { channel: "C0ABCDEF", threadTs: "1748600000.100000" }, + ts: "1748600000.500000", + user: "U1", + text: "@bot fix the bug", +}; +const SANDBOX = "t-C0ABCDEF-1748600000-100000"; // chooseSandboxName encodes '.' as '-' +const HOME = "/home/agent"; + +class FakePlatform { + replies: string[] = []; + constructor( + private readonly trace: string[], + private readonly whole: ThreadMessage[], + private readonly delta: ThreadMessage[] = [], + ) {} + async fetchThread(_thread: ThreadId, sinceTs?: string): Promise { + this.trace.push(`fetch:${sinceTs ?? "all"}`); + return sinceTs === undefined ? this.whole : this.delta; + } + async postReply(_thread: ThreadId, text: string): Promise { + this.trace.push("reply"); + this.replies.push(text); + } + async addReaction(_c: string, _ts: string, emoji: string): Promise { + this.trace.push(`+${emoji}`); + } + async removeReaction(_c: string, _ts: string, emoji: string): Promise { + this.trace.push(`-${emoji}`); + } + async uploadFile(): Promise {} +} + +class FakeSandbox { + readonly files = new Map(); + readonly existing: SandboxHandle[]; + failWrites = new Set(); + execArgs: string[][] = []; + shellResults: ExecResult[] = []; + shellCommands: string[] = []; + constructor( + private readonly trace: string[], + private readonly execResult: ExecResult, + existing: string[] = [], + ) { + this.existing = existing.map((name) => ({ name })); + } + async list(): Promise { + return this.existing; + } + async create(name: string, _repoRef: string): Promise { + this.trace.push(`create:${name}`); + this.existing.push({ name }); + return { name }; + } + lastCwd: string | undefined; + async exec(_h: SandboxHandle, argv: string[], opts?: ExecCallOptions): Promise { + this.trace.push("exec"); + this.execArgs.push(argv); + this.lastCwd = opts?.cwd; + opts?.onChunk?.("stdout", this.execResult.stdout); + opts?.onChunk?.("stderr", this.execResult.stderr); + return this.execResult; + } + async execShell(_h: SandboxHandle, command: string): Promise { + this.trace.push(`shell:${command.split(" ")[0]}`); + this.shellCommands.push(command); + return this.shellResults.shift() ?? { stdout: "", stderr: "", exitCode: 0 }; + } + async homeDir(): Promise { + return HOME; + } + async readFile(_h: SandboxHandle, absPath: string): Promise { + return this.files.get(absPath) ?? null; + } + async writeFile(_h: SandboxHandle, absPath: string, content: string): Promise { + this.trace.push(`write:${absPath.split("/").pop()}`); + if (this.failWrites.has(absPath)) { + throw new Error(`write failed: ${absPath}`); + } + this.files.set(absPath, content); + } + async getFile(): Promise { + return new Uint8Array(); + } + async putFile(): Promise {} + async stop(): Promise {} + async destroy(): Promise {} +} + +class FakeBackend implements CodingBackend { + turnArgsCalls: (string | undefined)[] = []; + constructor( + private readonly trace: string[], + private readonly result: TurnResult, + private readonly streamId: string | null = "sess-new", + ) {} + configHome(): string { + return "~/.codex"; + } + turnArgs(message: string, sessionId?: string): string[] { + this.turnArgsCalls.push(sessionId); + return ["codex", "exec", ...(sessionId ? ["resume", sessionId] : []), message]; + } + parseResult(): TurnResult { + return this.result; + } + parseSessionId(): string | null { + return this.streamId; + } + async captureSessionId(): Promise { + this.trace.push("captureSessionId"); + return "captured-id"; + } + events(): unknown[] { + return []; + } +} + +interface Built { + dispatcher: Dispatcher; + trace: string[]; + platform: FakePlatform; + sandbox: FakeSandbox; + backend: FakeBackend; + sink: ReturnType; +} + +function build(opts: { + whole?: ThreadMessage[]; + delta?: ThreadMessage[]; + exec?: ExecResult; + result?: TurnResult; + existing?: string[]; + streamId?: string | null; + seedFiles?: Record; + level?: "summary" | "verbose"; + provisionScript?: string; +}): Built { + const trace: string[] = []; + const platform = new FakePlatform( + trace, + opts.whole ?? [msg("1748600000.100000", "U1", "start"), msg("1748600000.500000", "U1", "fix")], + opts.delta ?? [], + ); + const sandbox = new FakeSandbox( + trace, + opts.exec ?? { stdout: '{"type":"item.completed"}', stderr: "", exitCode: 0 }, + opts.existing ?? [], + ); + for (const [path, content] of Object.entries(opts.seedFiles ?? {})) { + sandbox.files.set(path, content); + } + const backend = new FakeBackend( + trace, + opts.result ?? { finalText: "Fixed it.", ok: true }, + opts.streamId === undefined ? "sess-new" : opts.streamId, + ); + const sink = createRecordingSink(); + const dispatcher = new Dispatcher({ + platform, + sandbox, + backend, + repoRef: "/repo", + logSink: sink, + level: opts.level ?? "summary", + provisionScript: opts.provisionScript, + now: () => 1_700_000_000_000, + newTurnId: () => "turn-test", + }); + return { dispatcher, trace, platform, sandbox, backend, sink }; +} + +describe("formatPrompt", () => { + it("renders messages plainly, no editorializing (router is plumbing)", () => { + expect(formatPrompt([msg("1", "U1", "a"), msg("2", "U2", "b")])).toBe("U1: a\n\nU2: b"); + }); +}); + +describe("dispatchTurn — turn 1 happy path", () => { + it("creates the sandbox, primes, execs, persists, relays, and ✅s — in order", async () => { + const b = build({}); + const out = await b.dispatcher.dispatchTurn(trigger); + + expect(out).toEqual({ ok: true }); + expect(b.platform.replies).toEqual(["Fixed it."]); + expect(b.backend.turnArgsCalls).toEqual([undefined]); // fresh turn + + const t = b.trace; + const at = (label: string) => t.indexOf(label); + expect(at(`create:${SANDBOX}`)).toBeGreaterThanOrEqual(0); + expect(at("fetch:all")).toBeGreaterThan(at(`create:${SANDBOX}`)); // whole-thread primer + expect(at("exec")).toBeGreaterThan(at("fetch:all")); + // session persisted BEFORE the reply; transcript appended AFTER it + expect(at("write:session")).toBeGreaterThan(at("exec")); + expect(at("write:session")).toBeLessThan(at("reply")); + expect(at("write:transcript.jsonl")).toBeGreaterThan(at("reply")); + // 👀 -> ✅ swap is last + expect(t.slice(-2)).toEqual(["-eyes", "+white_check_mark"]); + // provisioned the repo and ran the agent IN it + expect(t).toContain("shell:test"); // the `test -d …/.git || git clone …` provision + expect(b.sandbox.lastCwd).toBe("/home/agent/repo"); + expect(b.sandbox.shellCommands.some((cmd) => cmd.includes("remote set-url origin"))).toBe(true); + expect(b.sandbox.shellCommands.some((cmd) => cmd.includes("checkout -q -B slack/work"))).toBe( + true, + ); + }); + + it("reuses an existing sandbox instead of creating one", async () => { + const b = build({ existing: [SANDBOX] }); + await b.dispatcher.dispatchTurn(trigger); + expect(b.trace).not.toContain(`create:${SANDBOX}`); + expect(b.trace).toContain("exec"); + }); + + it("runs the optional setup script only when creating a sandbox", async () => { + const created = build({ provisionScript: "setup-agent" }); + await created.dispatcher.dispatchTurn(trigger); + expect(created.sandbox.shellCommands).toContain("setup-agent"); + + const existing = build({ existing: [SANDBOX], provisionScript: "setup-agent" }); + await existing.dispatcher.dispatchTurn(trigger); + expect(existing.sandbox.shellCommands).not.toContain("setup-agent"); + }); + + it("falls back to captureSessionId when the stream has no id", async () => { + const b = build({ streamId: null }); + await b.dispatcher.dispatchTurn(trigger); + expect(b.trace).toContain("captureSessionId"); + expect(b.sandbox.files.get(`${HOME}/.agent-state/session`)).toBe("captured-id\n"); + }); + + it("emits boundary logs: sandbox.create, turn.in, turn.exit, result.relayed", async () => { + const b = build({}); + await b.dispatcher.dispatchTurn(trigger); + const kinds = b.sink.records.map((r) => r.kind); + expect(kinds).toContain("sandbox.create"); + expect(kinds).toContain("turn.in"); + expect(kinds).toContain("turn.exit"); + expect(kinds).toContain("result.relayed"); + const turnIn = b.sink.records.find((r) => r.kind === "turn.in"); + expect(turnIn?.sessionId).toBeNull(); + expect(turnIn?.resume).toBe(false); + }); +}); + +describe("dispatchTurn — follow-up (resume)", () => { + it("resumes the session and feeds only the delta after the hwm", async () => { + const seeded = serializeTranscript([ + msg("1748600000.100000", "U1", "start"), + msg("1748600000.500000", "U1", "fix"), + ]); + const b = build({ + existing: [SANDBOX], + seedFiles: { + [`${HOME}/.agent-state/session`]: "sess-1\n", + [`${HOME}/.agent-state/transcript.jsonl`]: seeded, + }, + delta: [msg("1748600000.700000", "U2", "also add a test")], + }); + const out = await b.dispatcher.dispatchTurn(trigger); + + expect(out).toEqual({ ok: true }); + expect(b.backend.turnArgsCalls).toEqual(["sess-1"]); // resume + expect(b.trace).toContain("fetch:1748600000.500000"); // sinceTs = hwm + expect(b.trace).not.toContain("write:session"); // not turn 1 + expect(b.trace).toContain("write:transcript.jsonl"); // delta appended + }); +}); + +describe("dispatchTurn — failure & skip", () => { + it("on !ok: posts the failure message, ❌s, and does NOT persist (re-feed)", async () => { + const b = build({ result: { finalText: "", ok: false } }); + const out = await b.dispatcher.dispatchTurn(trigger); + + expect(out).toEqual({ ok: false }); + expect(b.platform.replies[0]).toMatch(/did not finish/); + expect(b.trace).not.toContain("write:session"); + expect(b.trace).not.toContain("write:transcript.jsonl"); + expect(b.trace.slice(-2)).toEqual(["-eyes", "+x"]); + expect(b.sink.records.map((r) => r.kind)).toContain("turn.abandoned"); + }); + + it("on !ok WITH an error message, relays the agent's error (e.g. usage limit)", async () => { + const b = build({ result: { finalText: "You've hit your usage limit.", ok: false } }); + await b.dispatcher.dispatchTurn(trigger); + expect(b.platform.replies[0]).toBe("You've hit your usage limit."); + expect(b.trace).not.toContain("write:transcript.jsonl"); // still not persisted (re-feed) + }); + + it("on provisioning failure: does not run the agent, posts failure, and ❌s", async () => { + const b = build({}); + b.sandbox.shellResults.push({ stdout: "", stderr: "clone failed", exitCode: 1 }); + const out = await b.dispatcher.dispatchTurn(trigger); + + expect(out).toEqual({ ok: false }); + expect(b.trace).not.toContain("exec"); + expect(b.platform.replies[0]).toMatch(/did not finish/); + expect(b.trace.slice(-2)).toEqual(["-eyes", "+x"]); + expect(b.sink.records.map((r) => r.kind)).toContain("provision.failed"); + expect(b.sink.records.map((r) => r.kind)).toContain("turn.abandoned"); + }); + + it("does not post a generic failure after the agent reply already posted", async () => { + const b = build({}); + b.sandbox.failWrites.add(`${HOME}/.agent-state/transcript.jsonl`); + const out = await b.dispatcher.dispatchTurn(trigger); + + expect(out).toEqual({ ok: false }); + expect(b.platform.replies).toEqual(["Fixed it."]); + expect(b.trace.slice(-2)).toEqual(["-eyes", "+x"]); + expect(b.sink.records.map((r) => r.kind)).toContain("turn.abandoned"); + }); + + it("on empty delta: skips exec entirely and ✅s", async () => { + const b = build({ + existing: [SANDBOX], + seedFiles: { + [`${HOME}/.agent-state/session`]: "sess-1\n", + // a transcript sets the hwm so the follow-up fetch uses sinceTs (-> empty delta) + [`${HOME}/.agent-state/transcript.jsonl`]: serializeTranscript([ + msg("1748600000.500000", "U1", "fix"), + ]), + }, + delta: [], // nothing new since the hwm + }); + const out = await b.dispatcher.dispatchTurn(trigger); + expect(out).toEqual({ ok: true }); + expect(b.trace).not.toContain("exec"); + expect(b.trace.slice(-2)).toEqual(["-eyes", "+white_check_mark"]); + }); + + it("never rejects, even when a seam throws (turn abandoned, ❌)", async () => { + const b = build({}); + b.sandbox.list = async () => { + throw new Error("daemon down"); + }; + const out = await b.dispatcher.dispatchTurn(trigger); + expect(out).toEqual({ ok: false }); + expect(b.platform.replies[0]).toMatch(/did not finish/); + expect(b.sink.records.map((r) => r.kind)).toContain("turn.abandoned"); + }); +}); + +describe("dispatchTurn — verbose logging", () => { + it("streams raw out chunks only at verbose level", async () => { + const verbose = build({ + level: "verbose", + exec: { stdout: "raw-bytes", stderr: "", exitCode: 0 }, + }); + await verbose.dispatcher.dispatchTurn(trigger); + expect(verbose.sink.records.some((r) => r.kind === "turn.out.chunk")).toBe(true); + + const summary = build({}); + await summary.dispatcher.dispatchTurn(trigger); + expect(summary.sink.records.some((r) => r.kind === "turn.out.chunk")).toBe(false); + }); +}); diff --git a/src/router/dispatcher.ts b/src/router/dispatcher.ts new file mode 100644 index 0000000..3846599 --- /dev/null +++ b/src/router/dispatcher.ts @@ -0,0 +1,276 @@ +import { randomUUID } from "node:crypto"; +import type { CodingBackend } from "../backend/index.js"; +import type { PlatformAdapter } from "../platform/index.js"; +import type { SandboxHandle, SandboxProvider } from "../sandbox/index.js"; +import type { Mention, ThreadId, ThreadMessage } from "../types.js"; +import { computeDelta, highWaterMark } from "./agent-state.js"; +import { AgentStateStore, type SandboxFsLike } from "./agent-state-store.js"; +import { + buildOutChunk, + buildRouterEvent, + buildTurnExit, + buildTurnIn, + type LogContext, + type LogLevel, + type LogSink, + nullSink, + RouterKind, + safeWrite, +} from "./log.js"; +import { chooseSandboxName } from "./sandbox-name.js"; + +export interface DispatcherDeps { + platform: Pick; + sandbox: SandboxProvider & SandboxFsLike; + backend: CodingBackend; + repoRef: string; + logSink?: LogSink; + botUser?: string; + level?: LogLevel; + failureMessage?: string; + /** Optional setup run once after sandbox creation. */ + provisionScript?: string; + now?: () => number; + newTurnId?: () => string; +} + +const DEFAULT_FAILURE = "The coding agent did not finish that turn. Mention me again to retry."; + +const SBX_CLONE_SOURCE = "/run/sandbox/source"; +const IN_VM_REPO_DIR = "repo"; +const WORK_BRANCH = "slack/work"; +const BARE_SHELL_WORD = /^[A-Za-z0-9_/.:=@%+,-]+$/; + +function shellQuote(value: string): string { + if (value === "") { + return "''"; + } + if (BARE_SHELL_WORD.test(value)) { + return value; + } + return `'${value.replace(/'/g, "'\\''")}'`; +} + +/** Render thread messages into the prompt passed to the coding agent. */ +export function formatPrompt(messages: ThreadMessage[]): string { + return messages.map((m) => `${m.user}: ${m.text}`).join("\n\n"); +} + +export class Dispatcher { + private readonly platform: DispatcherDeps["platform"]; + private readonly sandbox: SandboxProvider & SandboxFsLike; + private readonly backend: CodingBackend; + private readonly repoRef: string; + private readonly logSink: LogSink; + private readonly botUser: string | undefined; + private readonly level: LogLevel; + private readonly failureMessage: string; + private readonly provisionScript: string | undefined; + private readonly now: () => number; + private readonly newTurnId: () => string; + + constructor(deps: DispatcherDeps) { + this.platform = deps.platform; + this.sandbox = deps.sandbox; + this.backend = deps.backend; + this.repoRef = deps.repoRef; + this.logSink = deps.logSink ?? nullSink; + this.botUser = deps.botUser; + this.level = deps.level ?? "summary"; + this.failureMessage = deps.failureMessage ?? DEFAULT_FAILURE; + this.provisionScript = deps.provisionScript; + this.now = deps.now ?? Date.now; + this.newTurnId = deps.newTurnId ?? (() => randomUUID()); + } + + async dispatchTurn(trigger: Mention): Promise<{ ok: boolean }> { + const thread = trigger.thread; + const name = chooseSandboxName(thread); + let replyPosted = false; + const ctx: LogContext = { + threadId: name, + channel: thread.channel, + thread_ts: thread.threadTs, + sandbox: name, + turnId: this.newTurnId(), + }; + try { + const handle = await this.resolveSandbox(thread, name, ctx); + const store = new AgentStateStore(this.sandbox, handle); + + const sessionId = await store.readSessionId(); + const delta = await this.assembleDelta(thread, store, sessionId); + if (delta.length === 0) { + this.router(ctx, "turn.skipped", { reason: "empty delta" }); + await this.swapReaction(trigger, true); + return { ok: true }; + } + + const prompt = formatPrompt(delta); + const argv = this.backend.turnArgs(prompt, sessionId ?? undefined); + + const home = await this.sandbox.homeDir(handle); + const repoPath = `${home}/${IN_VM_REPO_DIR}`; + await this.provisionRepo(handle, repoPath, ctx); + + this.write( + buildTurnIn(ctx, this.ts(), { + sessionId, + argv, + cwd: repoPath, + prompt, + level: this.level, + }), + ); + + const start = this.now(); + const { stdout, stderr, exitCode } = await this.sandbox.exec(handle, argv, { + cwd: repoPath, + onChunk: (stream, chunk) => { + if (this.level === "verbose") { + this.write(buildOutChunk(ctx, this.ts(), { stream, chunk })); + } + }, + }); + const result = this.backend.parseResult(stdout); + this.write( + buildTurnExit(ctx, this.ts(), { + exitCode, + durationMs: this.now() - start, + finalSnippet: result.finalText, + ok: result.ok, + }), + ); + + if (!result.ok) { + this.router(ctx, RouterKind.TurnAbandoned, { + reason: "agent returned no result", + exitCode, + stderrSnippet: stderr.slice(0, 500), + }); + const reply = result.finalText.trim() !== "" ? result.finalText : this.failureMessage; + await this.relay(thread, reply); + replyPosted = true; + await this.swapReaction(trigger, false); + return { ok: false }; + } + + if (sessionId === null) { + const id = + this.backend.parseSessionId?.(stdout) ?? (await this.backend.captureSessionId(handle)); + await store.writeSessionId(id); + } + await this.relay(thread, result.finalText); + replyPosted = true; + await store.appendDelta(delta); + await this.swapReaction(trigger, true); + this.router(ctx, RouterKind.ResultRelayed, { ok: true, chars: result.finalText.length }); + return { ok: true }; + } catch (err) { + this.router(ctx, RouterKind.TurnAbandoned, { error: errorMessage(err) }); + if (!replyPosted) { + await this.relayFailure(thread); + } + await this.swapReaction(trigger, false); + return { ok: false }; + } + } + + private async resolveSandbox( + thread: ThreadId, + name: string, + ctx: LogContext, + ): Promise { + const existing = (await this.sandbox.list()).find((h) => h.name === name); + if (existing !== undefined) { + return existing; + } + this.router(ctx, RouterKind.SandboxCreate, { name }); + const handle = await this.sandbox.create(name, this.repoRef); + // Reverse-map the full id so a hash-named sandbox is reversible. + await new AgentStateStore(this.sandbox, handle).writeThread( + `${thread.channel} ${thread.threadTs}`, + ); + if (this.provisionScript !== undefined) { + await this.sandbox.execShell(handle, this.provisionScript); + } + return handle; + } + + private async provisionRepo( + handle: SandboxHandle, + repoPath: string, + ctx: LogContext, + ): Promise { + const source = shellQuote(SBX_CLONE_SOURCE); + const repo = shellQuote(repoPath); + const fallbackOrigin = shellQuote(this.repoRef); + const branch = shellQuote(WORK_BRANCH); + const script = [ + `test -d ${repo}/.git || git clone -q ${source} ${repo}`, + `origin="$(git -C ${source} remote get-url origin 2>/dev/null || printf %s ${fallbackOrigin})"`, + `{ git -C ${repo} remote get-url origin >/dev/null 2>&1 && git -C ${repo} remote set-url origin "$origin" || git -C ${repo} remote add origin "$origin"; }`, + `git -C ${repo} checkout -q -B ${branch}`, + ].join(" && "); + const result = await this.sandbox.execShell(handle, script); + if (result.exitCode !== 0) { + this.router(ctx, "provision.failed", { stderrSnippet: result.stderr.slice(0, 300) }); + throw new Error(`repo provisioning failed: ${result.stderr.trim() || "unknown error"}`); + } + } + + private async assembleDelta( + thread: ThreadId, + store: AgentStateStore, + sessionId: string | null, + ): Promise { + if (sessionId === null) { + const fetched = await this.platform.fetchThread(thread); + return computeDelta(fetched, null, { botUser: this.botUser }); + } + const hwm = highWaterMark(await store.readTranscript()); + const fetched = await this.platform.fetchThread(thread, hwm ?? undefined); + return computeDelta(fetched, hwm, { botUser: this.botUser }); + } + + private async relay(thread: ThreadId, text: string): Promise { + await this.platform.postReply(thread, text); + } + + private async relayFailure(thread: ThreadId): Promise { + try { + await this.relay(thread, this.failureMessage); + } catch { + // The reaction still gives the user a failure signal if posting is unavailable. + } + } + + private async swapReaction(trigger: Mention, ok: boolean): Promise { + try { + await this.platform.removeReaction(trigger.thread.channel, trigger.ts, "eyes"); + await this.platform.addReaction( + trigger.thread.channel, + trigger.ts, + ok ? "white_check_mark" : "x", + ); + } catch { + // best-effort + } + } + + private ts(): string { + return new Date(this.now()).toISOString(); + } + + private write(env: ReturnType): void { + safeWrite(this.logSink, env); + } + + private router(ctx: LogContext, kind: string, payload: Record): void { + safeWrite(this.logSink, buildRouterEvent(ctx, this.ts(), kind, payload)); + } +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} diff --git a/src/router/idle-sweep.test.ts b/src/router/idle-sweep.test.ts new file mode 100644 index 0000000..ee2f832 --- /dev/null +++ b/src/router/idle-sweep.test.ts @@ -0,0 +1,159 @@ +import type { SandboxHandle } from "../sandbox/index.js"; +import type { ThreadId, ThreadMessage } from "../types.js"; +import { IdleSweeper, parseThreadRef, selectIdle, tsToMs } from "./idle-sweep.js"; + +const msg = (ts: string): ThreadMessage => ({ ts, user: "U1", text: "x" }); + +describe("tsToMs", () => { + it("converts Slack ts to epoch ms", () => { + expect(tsToMs("1748600000.123456")).toBeCloseTo(1748600000123.456, 0); + }); +}); + +describe("parseThreadRef", () => { + it("parses a 'channel threadTs' reverse-map line", () => { + expect(parseThreadRef("C0ABCDEF 1748600000.123456\n")).toEqual({ + channel: "C0ABCDEF", + threadTs: "1748600000.123456", + }); + }); + it("returns null for malformed input", () => { + expect(parseThreadRef("nospace")).toBeNull(); + expect(parseThreadRef("")).toBeNull(); + }); +}); + +describe("selectIdle", () => { + const now = 1_000_000; + const idleMs = 1000; + it("selects only sandboxes idle for >= idleMs", () => { + expect( + selectIdle( + [ + { name: "old", lastActivityMs: now - 2000 }, + { name: "fresh", lastActivityMs: now - 500 }, + { name: "exactly", lastActivityMs: now - 1000 }, + ], + now, + idleMs, + ), + ).toEqual(["old", "exactly"]); + }); + it("never evicts sandboxes with unknown activity", () => { + expect(selectIdle([{ name: "unknown", lastActivityMs: null }], now, idleMs)).toEqual([]); + }); +}); + +class FakeSweepSandbox { + stopped: string[] = []; + failStops = new Set(); + readonly files = new Map(); + constructor(private readonly handles: SandboxHandle[]) {} + async list(): Promise { + return this.handles; + } + async stop(handle: SandboxHandle): Promise { + if (this.failStops.has(handle.name)) { + throw new Error(`stop failed: ${handle.name}`); + } + this.stopped.push(handle.name); + } + async homeDir(): Promise { + return "/home/agent"; + } + async readFile(handle: SandboxHandle, absPath: string): Promise { + return this.files.get(`${handle.name}:${absPath}`) ?? this.files.get(absPath) ?? null; + } + async writeFile(): Promise {} + // unused provider surface + async create(): Promise { + return { name: "x" }; + } + async exec(): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return { stdout: "", stderr: "", exitCode: 0 }; + } + async execShell(): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return { stdout: "", stderr: "", exitCode: 0 }; + } + async getFile(): Promise { + return new Uint8Array(); + } + async putFile(): Promise {} + async destroy(): Promise {} +} + +class FakeSweepPlatform { + fetched: ThreadId[] = []; + constructor(private readonly byThread: Map) {} + async fetchThread(thread: ThreadId): Promise { + this.fetched.push(thread); + return this.byThread.get(`${thread.channel}:${thread.threadTs}`) ?? []; + } +} + +describe("IdleSweeper.sweepOnce", () => { + const NOW = tsToMs("1748600100.000000"); + + it("stops idle threads, keeps fresh ones, and ignores foreign sandboxes", async () => { + const handles: SandboxHandle[] = [ + { name: "t-C0ABCDEF-1748600000-000000" }, // idle (100s ago) + { name: "t-C0FRESH-1748600099-000000" }, // fresh (1s ago) + { name: "_login-tmp" }, // foreign — never touch + ]; + const sandbox = new FakeSweepSandbox(handles); + const platform = new FakeSweepPlatform( + new Map([ + ["C0ABCDEF:1748600000.000000", [msg("1748600000.000000")]], + ["C0FRESH:1748600099.000000", [msg("1748600099.000000")]], + ]), + ); + const sweeper = new IdleSweeper({ sandbox, platform, idleMs: 10_000, now: () => NOW }); + + const { stopped } = await sweeper.sweepOnce(); + expect(stopped).toEqual(["t-C0ABCDEF-1748600000-000000"]); + expect(sandbox.stopped).toEqual(["t-C0ABCDEF-1748600000-000000"]); + }); + + it("reverses a hash-named sandbox via ~/.agent-state/thread", async () => { + const hashName = "t-0123456789abcdef"; + const sandbox = new FakeSweepSandbox([{ name: hashName }]); + sandbox.files.set("/home/agent/.agent-state/thread", "C0HASH 1748600000.000000\n"); + const platform = new FakeSweepPlatform( + new Map([["C0HASH:1748600000.000000", [msg("1748600000.000000")]]]), + ); + const sweeper = new IdleSweeper({ sandbox, platform, idleMs: 10_000, now: () => NOW }); + + expect((await sweeper.sweepOnce()).stopped).toEqual([hashName]); + }); + + it("ignores hash-named sandboxes with missing or malformed reverse-map state", async () => { + const malformed = "t-0123456789abcdef"; + const missing = "t-fedcba9876543210"; + const sandbox = new FakeSweepSandbox([{ name: malformed }, { name: missing }]); + sandbox.files.set(`${malformed}:/home/agent/.agent-state/thread`, "malformed"); + const platform = new FakeSweepPlatform(new Map()); + const sweeper = new IdleSweeper({ sandbox, platform, idleMs: 10_000, now: () => NOW }); + + expect((await sweeper.sweepOnce()).stopped).toEqual([]); + expect(platform.fetched).toEqual([]); + }); + + it("continues sweeping when one idle sandbox fails to stop", async () => { + const handles: SandboxHandle[] = [ + { name: "t-C0A-1748600000-000000" }, + { name: "t-C0B-1748600000-000000" }, + ]; + const sandbox = new FakeSweepSandbox(handles); + sandbox.failStops.add("t-C0A-1748600000-000000"); + const platform = new FakeSweepPlatform( + new Map([ + ["C0A:1748600000.000000", [msg("1748600000.000000")]], + ["C0B:1748600000.000000", [msg("1748600000.000000")]], + ]), + ); + const sweeper = new IdleSweeper({ sandbox, platform, idleMs: 10_000, now: () => NOW }); + + expect((await sweeper.sweepOnce()).stopped).toEqual(["t-C0B-1748600000-000000"]); + expect(sandbox.stopped).toEqual(["t-C0B-1748600000-000000"]); + }); +}); diff --git a/src/router/idle-sweep.ts b/src/router/idle-sweep.ts new file mode 100644 index 0000000..3884cf5 --- /dev/null +++ b/src/router/idle-sweep.ts @@ -0,0 +1,144 @@ +import type { PlatformAdapter } from "../platform/index.js"; +import type { SandboxHandle, SandboxProvider } from "../sandbox/index.js"; +import type { ThreadId } from "../types.js"; +import { highWaterMark } from "./agent-state.js"; +import { AgentStateStore, type SandboxFsLike } from "./agent-state-store.js"; +import { + buildRouterEvent, + type LogContext, + type LogSink, + nullSink, + RouterKind, + safeWrite, +} from "./log.js"; +import { isHashName, parseSandboxName } from "./sandbox-name.js"; + +/** + * Idle eviction without stored timestamps: periodically `sbx ls` the + * running sandboxes, reverse each name back to its thread, read the last + * message time from Slack, and `sbx stop` the idle ones. `stop` is lossless and + * recoverable, so coarse timing is harmless; the router NEVER auto-`rm`s. + * + * One Slack call per running sandbox per sweep — fine at v0 scale. + */ + +/** sbx's documented idle-eviction window. */ +export const DEFAULT_IDLE_MS = 24 * 60 * 60 * 1000; + +/** Slack ts ("secs.micros") → epoch ms. */ +export function tsToMs(ts: string): number { + return Number.parseFloat(ts) * 1000; +} + +/** Parse a `~/.agent-state/thread` reverse-map line ("channel threadTs"). */ +export function parseThreadRef(raw: string): ThreadId | null { + const trimmed = raw.trim(); + const space = trimmed.indexOf(" "); + if (space < 0) { + return null; + } + const channel = trimmed.slice(0, space); + const threadTs = trimmed.slice(space + 1).trim(); + return channel !== "" && threadTs !== "" ? { channel, threadTs } : null; +} + +export interface SandboxActivity { + name: string; + /** Epoch ms of the thread's last message, or null if unknown (never evicted). */ + lastActivityMs: number | null; +} + +/** Names whose last activity is at least `idleMs` ago. Unknown activity is kept. */ +export function selectIdle(activities: SandboxActivity[], nowMs: number, idleMs: number): string[] { + return activities + .filter((a) => a.lastActivityMs !== null && nowMs - a.lastActivityMs >= idleMs) + .map((a) => a.name); +} + +export interface IdleSweeperDeps { + sandbox: SandboxProvider & SandboxFsLike; + platform: Pick; + idleMs?: number; + now?: () => number; + logSink?: LogSink; +} + +export class IdleSweeper { + private readonly sandbox: SandboxProvider & SandboxFsLike; + private readonly platform: Pick; + private readonly idleMs: number; + private readonly now: () => number; + private readonly logSink: LogSink; + + constructor(deps: IdleSweeperDeps) { + this.sandbox = deps.sandbox; + this.platform = deps.platform; + this.idleMs = deps.idleMs ?? DEFAULT_IDLE_MS; + this.now = deps.now ?? Date.now; + this.logSink = deps.logSink ?? nullSink; + } + + /** Map a sandbox handle back to its thread (deterministic name, then the reverse-map file). */ + private async threadOf(handle: SandboxHandle): Promise { + const parsed = parseSandboxName(handle.name); + if (parsed !== null) { + return parsed; + } + if (!isHashName(handle.name)) { + return null; // foreign/utility sandbox (e.g. _login-tmp) + } + const raw = await new AgentStateStore(this.sandbox, handle).readThread(); + return raw === null ? null : parseThreadRef(raw); + } + + /** One pass: stop every idle sandbox. Best-effort per sandbox. */ + async sweepOnce(): Promise<{ stopped: string[] }> { + const handles = await this.sandbox.list(); + const activities: SandboxActivity[] = []; + for (const handle of handles) { + const thread = await this.threadOf(handle); + if (thread === null) { + continue; // not one of ours — never touch it + } + const lastTs = highWaterMark(await this.platform.fetchThread(thread)); + activities.push({ + name: handle.name, + lastActivityMs: lastTs === null ? null : tsToMs(lastTs), + }); + } + const stopped: string[] = []; + for (const name of selectIdle(activities, this.now(), this.idleMs)) { + try { + await this.sandbox.stop({ name }); + this.logStop(name); + stopped.push(name); + } catch { + // best-effort: a failed stop is retried next sweep + } + } + return { stopped }; + } + + /** Run sweepOnce on an interval; returns a stop function. */ + start(intervalMs: number): () => void { + const timer = setInterval(() => { + void this.sweepOnce().catch(() => {}); + }, intervalMs); + timer.unref?.(); + return () => clearInterval(timer); + } + + private logStop(name: string): void { + const ctx: LogContext = { + threadId: name, + channel: "", + thread_ts: "", + sandbox: name, + turnId: "idle-sweep", + }; + safeWrite( + this.logSink, + buildRouterEvent(ctx, new Date().toISOString(), RouterKind.SandboxStop, { reason: "idle" }), + ); + } +} diff --git a/src/router/index.ts b/src/router/index.ts new file mode 100644 index 0000000..08737c9 --- /dev/null +++ b/src/router/index.ts @@ -0,0 +1,2 @@ +export { Dispatcher, type DispatcherDeps, formatPrompt } from "./dispatcher.js"; +export { Router, type RouterOptions, type TurnDispatcher } from "./router.js"; diff --git a/src/router/log.test.ts b/src/router/log.test.ts new file mode 100644 index 0000000..ac8fa01 --- /dev/null +++ b/src/router/log.test.ts @@ -0,0 +1,172 @@ +import { + buildOutChunk, + buildRouterEvent, + buildTurnExit, + buildTurnIn, + capArgv, + capForSummary, + createJsonlSink, + createRecordingSink, + hashString, + type LogContext, + type LogSink, + RouterKind, + safeWrite, + summarizePrompt, +} from "./log.js"; + +const ctx: LogContext = { + threadId: "t-C0ABCDEF-1748600000.123456", + channel: "C0ABCDEF", + thread_ts: "1748600000.123456", + sandbox: "t-C0ABCDEF-1748600000.123456", + turnId: "turn-1", +}; +const TS = "2026-05-31T00:00:00.000Z"; + +describe("envelope shape & correlation fields", () => { + it("every builder carries the common envelope", () => { + const env = buildRouterEvent(ctx, TS, RouterKind.MentionReceived, { user: "U1" }); + expect(env).toMatchObject({ + ts: TS, + threadId: ctx.threadId, + channel: ctx.channel, + thread_ts: ctx.thread_ts, + sandbox: ctx.sandbox, + turnId: ctx.turnId, + direction: "router", + kind: "mention.received", + user: "U1", + }); + }); + + it("assigns the right direction per builder", () => { + expect( + buildTurnIn(ctx, TS, { + sessionId: null, + argv: ["codex"], + cwd: "/w", + prompt: "p", + level: "summary", + }).direction, + ).toBe("in"); + expect(buildOutChunk(ctx, TS, { stream: "stdout", chunk: "x" }).direction).toBe("out"); + expect( + buildTurnExit(ctx, TS, { exitCode: 0, durationMs: 5, finalSnippet: "done", ok: true }) + .direction, + ).toBe("out"); + expect(buildRouterEvent(ctx, TS, RouterKind.AckReaction).direction).toBe("router"); + }); +}); + +describe("buildTurnIn — summary vs verbose", () => { + const longPrompt = "x".repeat(1000); + + it("summary caps argv and hashes/sizes the prompt (no full prompt)", () => { + const env = buildTurnIn(ctx, TS, { + sessionId: null, + argv: ["codex", "exec", longPrompt], + cwd: "/w", + prompt: longPrompt, + level: "summary", + }); + expect(env.kind).toBe("turn.in"); + expect(env.resume).toBe(false); + expect(env.sessionId).toBeNull(); + // prompt is summarized, not embedded whole + expect(env.prompt).toEqual({ + bytes: 1000, + sha256: hashString(longPrompt), + head: capForSummary(longPrompt, 200), + }); + // the giant prompt-arg in argv is capped too + const argv = env.argv as string[]; + expect(argv[2]?.length).toBeLessThan(longPrompt.length); + }); + + it("verbose embeds the full prompt and full argv", () => { + const env = buildTurnIn(ctx, TS, { + sessionId: "sess-123", + argv: ["codex", "exec", "resume", "sess-123", longPrompt], + cwd: "/w", + prompt: longPrompt, + level: "verbose", + }); + expect(env.resume).toBe(true); + expect(env.sessionId).toBe("sess-123"); + expect(env.prompt).toBe(longPrompt); + expect((env.argv as string[])[4]).toBe(longPrompt); + }); +}); + +describe("buildTurnExit / buildOutChunk", () => { + it("turn.exit carries exit/duration/ok and caps the snippet", () => { + const env = buildTurnExit(ctx, TS, { + exitCode: 1, + durationMs: 1234, + finalSnippet: "y".repeat(2000), + ok: false, + }); + expect(env).toMatchObject({ kind: "turn.exit", exitCode: 1, durationMs: 1234, ok: false }); + expect((env.finalSnippet as string).length).toBeLessThan(2000); + }); + + it("turn.out.chunk carries the raw stream + bytes", () => { + const env = buildOutChunk(ctx, TS, { stream: "stderr", chunk: "boom" }); + expect(env).toMatchObject({ kind: "turn.out.chunk", stream: "stderr", chunk: "boom" }); + }); +}); + +describe("safeguards", () => { + it("capForSummary truncates only when over the limit", () => { + expect(capForSummary("short", 10)).toBe("short"); + expect(capForSummary("abcdefghij", 3)).toBe("abc…(+7 chars)"); + }); + + it("hashString is deterministic 64-hex", () => { + expect(hashString("a")).toBe(hashString("a")); + expect(hashString("a")).toMatch(/^[0-9a-f]{64}$/); + }); + + it("summarizePrompt counts utf8 bytes (not chars)", () => { + expect(summarizePrompt("é").bytes).toBe(2); // 2 utf-8 bytes + }); + + it("capArgv shortens long elements", () => { + expect(capArgv(["short", "z".repeat(500)], 10)[1]).toContain("…"); + }); +}); + +describe("sinks — best-effort, never throwing", () => { + it("recording sink collects records", () => { + const sink = createRecordingSink(); + sink.write(buildRouterEvent(ctx, TS, RouterKind.AckReaction)); + expect(sink.records).toHaveLength(1); + expect(sink.records[0]?.kind).toBe("ack.reaction"); + }); + + it("jsonl sink serializes one line per record", () => { + const lines: string[] = []; + const sink = createJsonlSink((line) => lines.push(line)); + sink.write(buildRouterEvent(ctx, TS, RouterKind.SandboxStop, { reason: "idle" })); + expect(lines).toHaveLength(1); + expect(lines[0]?.endsWith("\n")).toBe(true); + expect(JSON.parse(lines[0] as string)).toMatchObject({ kind: "sandbox.stop", reason: "idle" }); + }); + + it("jsonl sink swallows a throwing writer (logging never fails a turn)", () => { + const sink = createJsonlSink(() => { + throw new Error("disk full"); + }); + expect(() => sink.write(buildRouterEvent(ctx, TS, RouterKind.Error))).not.toThrow(); + }); + + it("safeWrite swallows a throwing sink", () => { + const bad: LogSink = { + write() { + throw new Error("boom"); + }, + }; + expect(() => safeWrite(bad, buildRouterEvent(ctx, TS, RouterKind.Error))).not.toThrow(); + }); +}); diff --git a/src/router/log.ts b/src/router/log.ts new file mode 100644 index 0000000..071b646 --- /dev/null +++ b/src/router/log.ts @@ -0,0 +1,222 @@ +import { createHash } from "node:crypto"; + +/** + * Boundary logging. + * + * The router logs both edges of every turn at the agent boundary, treating the + * agent's output as opaque bytes — format-agnostic, so it survives Codex schema + * changes and catches the agent misbehaving (e.g. silent empty output). + * + * - `direction: "in"` — router -> agent (argv, target session, prompt) + * - `direction: "out"` — agent -> router (raw stdout/stderr chunks; exit summary) + * - `direction: "router"` — the router's own lifecycle actions + * + * Two levels: always-on **summaries** (cap/hash large prompts and output) and a + * **verbose** toggle for full raw bytes. Logs are a sink, never read back for + * correctness, and writes are best-effort — they must never stall or fail a turn. + * Credentials are proxy-side and never enter argv/prompt, so they are never + * logged. + */ + +export type Direction = "in" | "out" | "router"; +export type LogLevel = "summary" | "verbose"; + +/** Stable per-turn/per-thread correlation fields carried by every record. */ +export interface LogContext { + threadId: string; + channel: string; + thread_ts: string; + sandbox: string; + turnId: string; +} + +interface EnvelopeBase extends LogContext { + ts: string; + direction: Direction; + kind: string; +} + +/** Common envelope + kind-specific payload spread at the top level. */ +export type LogEnvelope = EnvelopeBase & Record; + +/** Router-direction record kinds — the gaps raw agent I/O can't explain. */ +export const RouterKind = { + MentionReceived: "mention.received", + MentionDeduped: "mention.deduped", + AckReaction: "ack.reaction", + QueuedPending: "queued.pending", + SandboxCreate: "sandbox.create", + SandboxStart: "sandbox.start", + SandboxStop: "sandbox.stop", + TurnAbandoned: "turn.abandoned", + ResultRelayed: "result.relayed", + FileUploaded: "file.uploaded", + Error: "error", +} as const; + +const SUMMARY_HEAD_CHARS = 200; +const SUMMARY_SNIPPET_CHARS = 500; + +// --------------------------------------------------------------------------- +// Pure safeguards +// --------------------------------------------------------------------------- + +/** Truncate long strings for summaries, noting how much was dropped. */ +export function capForSummary(text: string, maxChars: number): string { + if (text.length <= maxChars) { + return text; + } + return `${text.slice(0, maxChars)}…(+${text.length - maxChars} chars)`; +} + +export function hashString(text: string): string { + return createHash("sha256").update(text).digest("hex"); +} + +/** Size + hash + head of a (possibly huge) prompt — never the whole thing. */ +export function summarizePrompt(prompt: string): { + bytes: number; + sha256: string; + head: string; +} { + return { + bytes: Buffer.byteLength(prompt, "utf8"), + sha256: hashString(prompt), + head: capForSummary(prompt, SUMMARY_HEAD_CHARS), + }; +} + +/** Cap each argv element so a giant prompt-arg can't flood a summary record. */ +export function capArgv(argv: string[], maxChars: number = SUMMARY_HEAD_CHARS): string[] { + return argv.map((arg) => capForSummary(arg, maxChars)); +} + +// --------------------------------------------------------------------------- +// Pure record builders +// --------------------------------------------------------------------------- + +function base(ctx: LogContext, ts: string, direction: Direction, kind: string): EnvelopeBase { + return { + ts, + threadId: ctx.threadId, + channel: ctx.channel, + thread_ts: ctx.thread_ts, + sandbox: ctx.sandbox, + turnId: ctx.turnId, + direction, + kind, + }; +} + +export interface TurnInInput { + /** Target session id: null => fresh turn, else resume. */ + sessionId: string | null; + argv: string[]; + cwd: string; + prompt: string; + level: LogLevel; +} + +export function buildTurnIn(ctx: LogContext, ts: string, input: TurnInInput): LogEnvelope { + const { sessionId, argv, cwd, prompt, level } = input; + if (level === "verbose") { + return { + ...base(ctx, ts, "in", "turn.in"), + resume: sessionId !== null, + sessionId, + argv, + cwd, + prompt, + }; + } + return { + ...base(ctx, ts, "in", "turn.in"), + resume: sessionId !== null, + sessionId, + argv: capArgv(argv), + cwd, + prompt: summarizePrompt(prompt), + }; +} + +export function buildOutChunk( + ctx: LogContext, + ts: string, + input: { stream: "stdout" | "stderr"; chunk: string }, +): LogEnvelope { + return { ...base(ctx, ts, "out", "turn.out.chunk"), stream: input.stream, chunk: input.chunk }; +} + +export interface TurnExitInput { + exitCode: number; + durationMs: number; + finalSnippet: string; + ok: boolean; +} + +export function buildTurnExit(ctx: LogContext, ts: string, input: TurnExitInput): LogEnvelope { + return { + ...base(ctx, ts, "out", "turn.exit"), + exitCode: input.exitCode, + durationMs: input.durationMs, + finalSnippet: capForSummary(input.finalSnippet, SUMMARY_SNIPPET_CHARS), + ok: input.ok, + }; +} + +export function buildRouterEvent( + ctx: LogContext, + ts: string, + kind: string, + payload: Record = {}, +): LogEnvelope { + return { ...base(ctx, ts, "router", kind), ...payload }; +} + +// --------------------------------------------------------------------------- +// Sinks (best-effort, never throwing) +// --------------------------------------------------------------------------- + +export interface LogSink { + write(env: LogEnvelope): void; +} + +/** Discards everything (default when logging is off / in tests). */ +export const nullSink: LogSink = { write() {} }; + +/** In-memory sink for tests. */ +export function createRecordingSink(): LogSink & { records: LogEnvelope[] } { + const records: LogEnvelope[] = []; + return { + records, + write(env) { + records.push(env); + }, + }; +} + +/** + * Serialize each record to a JSONL line and hand it to `writeLine` (which does + * the actual stdout/file I/O, injected by the composition root). Swallows all + * errors — a full disk or slow shipper must never stall a turn. + */ +export function createJsonlSink(writeLine: (line: string) => void): LogSink { + return { + write(env) { + try { + writeLine(`${JSON.stringify(env)}\n`); + } catch { + // best-effort: logging is never allowed to fail a turn + } + }, + }; +} + +/** Write to a sink without ever letting a misbehaving sink throw into a turn. */ +export function safeWrite(sink: LogSink, env: LogEnvelope): void { + try { + sink.write(env); + } catch { + // best-effort + } +} diff --git a/src/router/router.test.ts b/src/router/router.test.ts new file mode 100644 index 0000000..92d8b58 --- /dev/null +++ b/src/router/router.test.ts @@ -0,0 +1,142 @@ +import type { Mention } from "../types.js"; +import { Router, type TurnDispatcher } from "./router.js"; + +const thread = { channel: "C0ABCDEF", threadTs: "1748600000.100000" }; +const mention = (ts: string): Mention => ({ thread, ts, user: "U1", text: `@bot ${ts}` }); +const m1 = mention("1748600000.100001"); +const m2 = mention("1748600000.100002"); +const m3 = mention("1748600000.100003"); + +const flush = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +/** A dispatcher whose turns stay in flight until finishOne() is called. */ +class FakeDispatcher implements TurnDispatcher { + calls: Mention[] = []; + private resolvers: (() => void)[] = []; + async dispatchTurn(trigger: Mention): Promise<{ ok: boolean }> { + this.calls.push(trigger); + await new Promise((resolve) => this.resolvers.push(resolve)); + return { ok: true }; + } + finishOne(): void { + this.resolvers.shift()?.(); + } + get inFlight(): number { + return this.resolvers.length; + } +} + +class FakePlatform { + reactions: string[] = []; + onMention(): void {} + async addReaction(channel: string, ts: string, emoji: string): Promise { + this.reactions.push(`${channel}:${ts}:${emoji}`); + } +} + +function build(): { router: Router; dispatcher: FakeDispatcher; platform: FakePlatform } { + const dispatcher = new FakeDispatcher(); + const platform = new FakePlatform(); + const router = new Router(platform, dispatcher); + return { router, dispatcher, platform }; +} + +describe("dedupe", () => { + it("drops a re-delivered ts (no second dispatch)", async () => { + const { router, dispatcher } = build(); + await router.onMention(m1); + await router.onMention({ ...m1 }); // same ts re-delivered + expect(dispatcher.calls).toHaveLength(1); + }); + + it("does not dedupe the same ts across different channels", async () => { + const { router, dispatcher } = build(); + const other = { + ...m1, + thread: { channel: "COTHER", threadTs: m1.thread.threadTs }, + }; + await router.onMention(m1); + await router.onMention(other); + expect(dispatcher.calls).toEqual([m1, other]); + }); +}); + +describe("idle/running/pending", () => { + it("dispatches on the first mention and 👀s it", async () => { + const { router, dispatcher, platform } = build(); + await router.onMention(m1); + expect(dispatcher.calls).toEqual([m1]); + expect(platform.reactions).toContain(`${thread.channel}:${m1.ts}:eyes`); + }); + + it("a mention during a running turn sets pending + 👀s, but does NOT dispatch", async () => { + const { router, dispatcher, platform } = build(); + await router.onMention(m1); // dispatch #1 (stays in flight) + await router.onMention(m2); // running -> pending + expect(dispatcher.calls).toEqual([m1]); // no new dispatch + expect(platform.reactions).toContain(`${thread.channel}:${m2.ts}:eyes`); // still 👀'd + }); + + it("👀s every mention", async () => { + const { router, platform } = build(); + await router.onMention(m1); + await router.onMention(m2); + await router.onMention(m3); + expect(platform.reactions).toEqual([ + `${thread.channel}:${m1.ts}:eyes`, + `${thread.channel}:${m2.ts}:eyes`, + `${thread.channel}:${m3.ts}:eyes`, + ]); + }); +}); + +describe("coalescing", () => { + it("N mentions during a turn produce exactly ONE coalesced follow-up (latest trigger)", async () => { + const { router, dispatcher } = build(); + await router.onMention(m1); // dispatch #1 + await router.onMention(m2); // pending + await router.onMention(m3); // still pending (idempotent) + expect(dispatcher.calls).toHaveLength(1); + + dispatcher.finishOne(); // turn 1 done -> coalesced follow-up + await flush(); + expect(dispatcher.calls).toEqual([m1, m3]); // exactly one follow-up, latest mention as trigger + + dispatcher.finishOne(); // follow-up done -> idle + await flush(); + expect(dispatcher.calls).toHaveLength(2); // nothing more + }); + + it("a single mention with no mid-turn mentions yields exactly one dispatch", async () => { + const { router, dispatcher } = build(); + await router.onMention(m1); + dispatcher.finishOne(); + await flush(); + expect(dispatcher.calls).toEqual([m1]); + }); +}); + +describe("atomicity at the finish boundary", () => { + it("a mention landing as a turn finishes is never lost", async () => { + const { router, dispatcher } = build(); + await router.onMention(m1); // dispatch #1 + + // Finish the turn and deliver m2 without awaiting in between — the per-thread + // mutex must serialize the turnFinished reduce and the mention reduce so m2 + // can't slip between "go idle" and "set pending". + dispatcher.finishOne(); + await router.onMention(m2); + await flush(); + + expect(dispatcher.calls).toContain(m2); + expect(dispatcher.calls).toHaveLength(2); + }); +}); + +describe("restart", () => { + it("a fresh Router starts idle (no persisted state) and dispatches", async () => { + const { router, dispatcher } = build(); + await router.onMention(m1); + expect(dispatcher.calls).toEqual([m1]); + }); +}); diff --git a/src/router/router.ts b/src/router/router.ts new file mode 100644 index 0000000..28c1569 --- /dev/null +++ b/src/router/router.ts @@ -0,0 +1,148 @@ +import type { PlatformAdapter } from "../platform/index.js"; +import type { Mention, ThreadId } from "../types.js"; +import { createTtlSet, type TtlSet } from "./dedupe.js"; +import { + buildRouterEvent, + type LogContext, + type LogSink, + nullSink, + RouterKind, + safeWrite, +} from "./log.js"; +import { chooseSandboxName } from "./sandbox-name.js"; +import { INITIAL_STATE, reduce, type ThreadFsmState } from "./state-machine.js"; + +export interface TurnDispatcher { + dispatchTurn(trigger: Mention): Promise<{ ok: boolean }>; +} + +export interface RouterOptions { + dedupeTtlMs?: number; + now?: () => number; + logSink?: LogSink; +} + +function threadKey(thread: ThreadId): string { + return `${thread.channel}:${thread.threadTs}`; +} + +function dedupeKey(mention: Mention): string { + return `${mention.thread.channel}:${mention.ts}`; +} + +export class Router { + private readonly states = new Map(); + private readonly latestMention = new Map(); + private readonly locks = new Map>(); + private readonly dedupe: TtlSet; + private readonly logSink: LogSink; + + constructor( + private readonly platform: Pick, + private readonly dispatcher: TurnDispatcher, + options: RouterOptions = {}, + ) { + this.dedupe = createTtlSet(options.dedupeTtlMs, options.now); + this.logSink = options.logSink ?? nullSink; + } + + start(): void { + this.platform.onMention((mention) => { + void this.onMention(mention); + }); + } + + async onMention(mention: Mention): Promise { + const key = threadKey(mention.thread); + const dedupe = dedupeKey(mention); + + if (this.dedupe.has(dedupe)) { + this.logRouter(mention.thread, mention.ts, RouterKind.MentionDeduped, { ts: mention.ts }); + return; + } + this.dedupe.add(dedupe); + this.logRouter(mention.thread, mention.ts, RouterKind.MentionReceived, { user: mention.user }); + this.latestMention.set(key, mention); + + const effects = await this.withLock(key, () => { + const { state, effects } = reduce(this.stateOf(key), { kind: "mention" }); + this.states.set(key, state); + return effects; + }); + + for (const effect of effects) { + if (effect === "ackRunning" || effect === "ackPending") { + void this.ack(mention); + } else if (effect === "dispatchTurn") { + this.dispatch(key, mention); + } + } + } + + private dispatch(key: string, trigger: Mention): void { + void this.dispatcher.dispatchTurn(trigger).then( + () => this.onTurnFinished(key), + () => this.onTurnFinished(key), + ); + } + + private onTurnFinished(key: string): void { + void this.withLock(key, () => { + const { state, effects } = reduce(this.stateOf(key), { kind: "turnFinished" }); + this.states.set(key, state); + return effects; + }).then((effects) => { + for (const effect of effects) { + if (effect === "dispatchTurn") { + const trigger = this.latestMention.get(key); + if (trigger !== undefined) { + this.dispatch(key, trigger); + } + } + } + }); + } + + private stateOf(key: string): ThreadFsmState { + return this.states.get(key) ?? INITIAL_STATE; + } + + private async ack(mention: Mention): Promise { + try { + await this.platform.addReaction(mention.thread.channel, mention.ts, "eyes"); + this.logRouter(mention.thread, mention.ts, RouterKind.AckReaction, { emoji: "eyes" }); + } catch { + // best-effort + } + } + + private withLock(key: string, fn: () => T): Promise { + const prev = this.locks.get(key) ?? Promise.resolve(); + const result = prev.then(fn, fn); + this.locks.set( + key, + result.then( + () => {}, + () => {}, + ), + ); + return result; + } + + private logRouter( + thread: ThreadId, + turnId: string, + kind: string, + payload: Record, + ): void { + const name = chooseSandboxName(thread); + const ctx: LogContext = { + threadId: name, + channel: thread.channel, + thread_ts: thread.threadTs, + sandbox: name, + turnId, + }; + safeWrite(this.logSink, buildRouterEvent(ctx, new Date().toISOString(), kind, payload)); + } +} diff --git a/src/router/sandbox-name.test.ts b/src/router/sandbox-name.test.ts new file mode 100644 index 0000000..707590b --- /dev/null +++ b/src/router/sandbox-name.test.ts @@ -0,0 +1,129 @@ +import type { ThreadId } from "../types.js"; +import { + chooseSandboxName, + hashSandboxName, + isCharsetSafe, + isHashName, + parseSandboxName, + SANDBOX_PREFIX, + sandboxName, +} from "./sandbox-name.js"; + +const SAMPLES: ThreadId[] = [ + { channel: "C0ABCDEF", threadTs: "1748600000.123456" }, + { channel: "G12345678", threadTs: "1700000000.000100" }, + { channel: "D0XYZ", threadTs: "1.2" }, + { channel: "C0ABCDEFGHIJ", threadTs: "1748600000.999999" }, +]; + +describe("sandboxName", () => { + it("encodes as t---, with '.' -> '-' (hostname-safe)", () => { + expect(sandboxName({ channel: "C0ABCDEF", threadTs: "1748600000.123456" })).toBe( + "t-C0ABCDEF-1748600000-123456", + ); + }); + + it("emits neither '_' nor '.'", () => { + for (const thread of SAMPLES) { + const name = sandboxName(thread); + expect(name).not.toContain("_"); + expect(name).not.toContain("."); + } + }); + + it("only ever emits hostname-safe characters [A-Za-z0-9-]", () => { + for (const thread of SAMPLES) { + expect(isCharsetSafe(sandboxName(thread))).toBe(true); + } + }); +}); + +describe("parseSandboxName (round-trip + namespacing)", () => { + it("round-trips every sample: parse(name(t)) === t", () => { + for (const thread of SAMPLES) { + expect(parseSandboxName(sandboxName(thread))).toEqual(thread); + } + }); + + it("is injective: distinct threads -> distinct names", () => { + const names = new Set(SAMPLES.map(sandboxName)); + expect(names.size).toBe(SAMPLES.length); + }); + + it("returns null for foreign / utility / malformed names", () => { + for (const name of [ + "_login-tmp", // sbx utility sandbox + "codex-myrepo", // default sbx name shape + "t-", // prefix only + "t-C0ABCDEF", // no thread_ts + "t-C0ABCDEF-notats", // thread_ts not numeric + "t-C0ABCDEF-1748600000", // missing fractional part + "t-C0ABCDEF-1748600000.123456", // old dotted form (no longer produced) + "C0ABCDEF-1748600000-1", // missing prefix + ]) { + expect(parseSandboxName(name)).toBeNull(); + } + }); + + it("returns null for hash names (caller reads ~/.agent-state/thread)", () => { + const hashed = hashSandboxName(SAMPLES[0] as ThreadId); + expect(parseSandboxName(hashed)).toBeNull(); + }); +}); + +describe("hash fallback", () => { + const thread = SAMPLES[0] as ThreadId; + + it("is deterministic, charset-safe, and recognized as a hash name", () => { + const a = hashSandboxName(thread); + const b = hashSandboxName(thread); + expect(a).toBe(b); + expect(a.startsWith(SANDBOX_PREFIX)).toBe(true); + expect(isCharsetSafe(a)).toBe(true); + expect(isHashName(a)).toBe(true); + expect(a).not.toContain("_"); + }); + + it("maps distinct threads to distinct hashes", () => { + expect(hashSandboxName(SAMPLES[0] as ThreadId)).not.toBe( + hashSandboxName(SAMPLES[1] as ThreadId), + ); + }); + + it("avoids collisions across the channel/ts boundary", () => { + // Without a separator, ("ab","c") and ("a","bc") could collide. + expect(hashSandboxName({ channel: "AB", threadTs: "1.2" })).not.toBe( + hashSandboxName({ channel: "A", threadTs: "B1.2" }), + ); + }); +}); + +describe("chooseSandboxName", () => { + const thread = SAMPLES[0] as ThreadId; + + it("uses the plain reversible name when it fits", () => { + const name = chooseSandboxName(thread); + expect(name).toBe(sandboxName(thread)); + expect(parseSandboxName(name)).toEqual(thread); + }); + + it("falls back to a hash name when the plain name exceeds maxLen", () => { + const name = chooseSandboxName(thread, 10); + expect(isHashName(name)).toBe(true); + expect(name).toBe(hashSandboxName(thread)); + }); +}); + +describe("isHashName / isCharsetSafe", () => { + it("distinguishes hash names from plain names", () => { + expect(isHashName("t-0123456789abcdef")).toBe(true); + expect(isHashName("t-C0ABCDEF-1748600000-123456")).toBe(false); + expect(isHashName("t-0123456789ABCDEF")).toBe(false); // uppercase != hex digest + }); + + it("flags BOTH underscores and periods as unsafe (hostname rules)", () => { + expect(isCharsetSafe("t-C0ABCDEF-1748600000_123456")).toBe(false); // illegal --name + expect(isCharsetSafe("t-C0ABCDEF-1748600000.123456")).toBe(false); // illegal hostname + expect(isCharsetSafe("t-C0ABCDEF-1748600000-123456")).toBe(true); + }); +}); diff --git a/src/router/sandbox-name.ts b/src/router/sandbox-name.ts new file mode 100644 index 0000000..0b29b56 --- /dev/null +++ b/src/router/sandbox-name.ts @@ -0,0 +1,50 @@ +import { createHash } from "node:crypto"; +import type { ThreadId } from "../types.js"; + +export const SANDBOX_PREFIX = "t-"; + +/** Single-label hostname limit. Longer thread ids use a hash fallback. */ +export const SBX_NAME_MAX_LEN = 63; + +const CHARSET_RE = /^[A-Za-z0-9-]+$/; +const PLAIN_RE = /^t-([A-Za-z0-9]+)-(\d+)-(\d+)$/; +const HASH_RE = /^t-[0-9a-f]{16}$/; + +export function sandboxName(thread: ThreadId): string { + return `${SANDBOX_PREFIX}${thread.channel}-${thread.threadTs.replace(".", "-")}`; +} + +export function isCharsetSafe(name: string): boolean { + return CHARSET_RE.test(name); +} + +export function isHashName(name: string): boolean { + return HASH_RE.test(name); +} + +export function hashSandboxName(thread: ThreadId): string { + const digest = createHash("sha256").update(`${thread.channel} ${thread.threadTs}`).digest("hex"); + return `${SANDBOX_PREFIX}${digest.slice(0, 16)}`; +} + +export function chooseSandboxName(thread: ThreadId, maxLen: number = SBX_NAME_MAX_LEN): string { + const plain = sandboxName(thread); + if (plain.length <= maxLen && isCharsetSafe(plain)) { + return plain; + } + return hashSandboxName(thread); +} + +export function parseSandboxName(name: string): ThreadId | null { + const match = PLAIN_RE.exec(name); + if (match === null) { + return null; + } + const channel = match[1]; + const secs = match[2]; + const micros = match[3]; + if (channel === undefined || secs === undefined || micros === undefined) { + return null; + } + return { channel, threadTs: `${secs}.${micros}` }; +} diff --git a/src/router/state-machine.test.ts b/src/router/state-machine.test.ts new file mode 100644 index 0000000..2c36c5a --- /dev/null +++ b/src/router/state-machine.test.ts @@ -0,0 +1,135 @@ +import { + type FsmEffect, + type FsmEvent, + INITIAL_STATE, + reduce, + type ThreadFsmState, +} from "./state-machine.js"; + +const mention: FsmEvent = { kind: "mention" }; +const turnFinished: FsmEvent = { kind: "turnFinished" }; + +/** Fold a sequence of events from a start state, collecting every effect. */ +function run( + start: ThreadFsmState, + events: FsmEvent[], +): { state: ThreadFsmState; effects: FsmEffect[] } { + let state = start; + const effects: FsmEffect[] = []; + for (const event of events) { + const t = reduce(state, event); + state = t.state; + effects.push(...t.effects); + } + return { state, effects }; +} + +describe("reduce — every state x event cell", () => { + it("idle + mention -> running [ackRunning, dispatchTurn]", () => { + expect(reduce("idle", mention)).toEqual({ + state: "running", + effects: ["ackRunning", "dispatchTurn"], + }); + }); + + it("running + mention -> runningPending [ackPending] (no dispatch)", () => { + expect(reduce("running", mention)).toEqual({ + state: "runningPending", + effects: ["ackPending"], + }); + }); + + it("runningPending + mention -> runningPending [ackPending] (idempotent)", () => { + expect(reduce("runningPending", mention)).toEqual({ + state: "runningPending", + effects: ["ackPending"], + }); + }); + + it("running + turnFinished -> idle [goIdle]", () => { + expect(reduce("running", turnFinished)).toEqual({ state: "idle", effects: ["goIdle"] }); + }); + + it("runningPending + turnFinished -> running [dispatchTurn] (coalesced follow-up)", () => { + expect(reduce("runningPending", turnFinished)).toEqual({ + state: "running", + effects: ["dispatchTurn"], + }); + }); + + it("idle + turnFinished -> idle [] (defensive no-op)", () => { + expect(reduce("idle", turnFinished)).toEqual({ state: "idle", effects: [] }); + }); +}); + +describe("coalescing", () => { + it("dispatches exactly once per turn regardless of mentions arriving mid-turn", () => { + // 1 initial mention + 5 mid-turn mentions, then the turn finishes, then the + // coalesced follow-up finishes. Expect exactly 2 dispatches total. + const events: FsmEvent[] = [ + mention, // -> running, dispatch #1 + mention, // -> pending + mention, + mention, + mention, + mention, // still pending (idempotent) + turnFinished, // -> running, dispatch #2 (coalesced) + turnFinished, // -> idle, no dispatch + ]; + const { state, effects } = run(INITIAL_STATE, events); + expect(effects.filter((e) => e === "dispatchTurn")).toHaveLength(2); + expect(state).toBe("idle"); + }); + + it("a single mention with no mid-turn mentions yields exactly one dispatch", () => { + const { state, effects } = run(INITIAL_STATE, [mention, turnFinished]); + expect(effects.filter((e) => e === "dispatchTurn")).toHaveLength(1); + expect(state).toBe("idle"); + }); + + it("acks every mention but only dispatches when idle (👀 on each)", () => { + const { effects } = run(INITIAL_STATE, [mention, mention, mention]); + const acks = effects.filter((e) => e === "ackRunning" || e === "ackPending"); + expect(acks).toHaveLength(3); // one 👀 per mention + expect(effects.filter((e) => e === "dispatchTurn")).toHaveLength(1); // only the first dispatched + }); + + it("orders the ack before the dispatch on idle+mention", () => { + const { effects } = reduce("idle", mention); + expect(effects.indexOf("ackRunning")).toBeLessThan(effects.indexOf("dispatchTurn")); + }); +}); + +describe("invariants", () => { + it("never dispatches a turn while one is already in flight", () => { + // Drive an interleaving of mentions and completions; a turnFinished clears + // the in-flight turn, after which the FSM may dispatch the coalesced one. + let state = INITIAL_STATE; + let inFlight = false; + const seq: FsmEvent[] = [ + mention, + mention, + turnFinished, + mention, + turnFinished, + turnFinished, + mention, + turnFinished, + ]; + for (const event of seq) { + const t = reduce(state, event); + if (event.kind === "turnFinished") { + inFlight = false; // the in-flight turn completed + } + for (const eff of t.effects) { + if (eff === "dispatchTurn") { + expect(inFlight).toBe(false); // never dispatch while a turn is in flight + inFlight = true; + } + } + state = t.state; + } + expect(state).toBe("idle"); + expect(inFlight).toBe(false); + }); +}); diff --git a/src/router/state-machine.ts b/src/router/state-machine.ts new file mode 100644 index 0000000..22c7b00 --- /dev/null +++ b/src/router/state-machine.ts @@ -0,0 +1,29 @@ +export type ThreadFsmState = "idle" | "running" | "runningPending"; + +export type FsmEvent = { kind: "mention" } | { kind: "turnFinished" }; + +export type FsmEffect = "ackRunning" | "ackPending" | "dispatchTurn" | "goIdle"; + +export interface Transition { + state: ThreadFsmState; + effects: FsmEffect[]; +} + +export const INITIAL_STATE: ThreadFsmState = "idle"; + +export function reduce(state: ThreadFsmState, event: FsmEvent): Transition { + switch (state) { + case "idle": + return event.kind === "mention" + ? { state: "running", effects: ["ackRunning", "dispatchTurn"] } + : { state: "idle", effects: [] }; + case "running": + return event.kind === "mention" + ? { state: "runningPending", effects: ["ackPending"] } + : { state: "idle", effects: ["goIdle"] }; + case "runningPending": + return event.kind === "mention" + ? { state: "runningPending", effects: ["ackPending"] } + : { state: "running", effects: ["dispatchTurn"] }; + } +} diff --git a/src/sandbox/index.ts b/src/sandbox/index.ts new file mode 100644 index 0000000..4827db7 --- /dev/null +++ b/src/sandbox/index.ts @@ -0,0 +1,53 @@ +/** + * SandboxProvider — isolated execution-environment lifecycle. + * + * v0: Docker Sandboxes (sbx) microVM. `exec` is the transport the router logs + * at the agent boundary; commands should be wrapped in `bash -c` so the + * sandbox's persistent environment is loaded. + * + * Fallback: plain Docker + iptables egress. Cloud/teams later: e2b / Daytona / + * self-hosted Firecracker. + */ +export interface SandboxHandle { + /** Deterministic, reversible name = f(threadId). */ + name: string; +} + +export interface ExecResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** Streaming options for a single exec call. */ +export interface ExecCallOptions { + /** Receive raw stdout/stderr chunks as they arrive (the router logs them). */ + onChunk?: (stream: "stdout" | "stderr", chunk: string) => void; + /** Optional input to write before closing stdin. Stdin is always closed. */ + stdin?: string; + /** Working directory inside the sandbox (e.g. the provisioned repo clone). */ + cwd?: string; +} + +export interface SandboxProvider { + create(name: string, repoRef: string): Promise; + + /** + * Drive the agent CLI per turn (argv wrapped in `bash -c`); the router streams + * and logs this raw output. The reader drains stdout+stderr to exit and + * closes stdin. + */ + exec(handle: SandboxHandle, argv: string[], opts?: ExecCallOptions): Promise; + + /** Run a raw shell command (utility ops: file reads, mkdir, $HOME, find). */ + execShell(handle: SandboxHandle, command: string, opts?: ExecCallOptions): Promise; + + getFile(handle: SandboxHandle, path: string): Promise; + putFile(handle: SandboxHandle, path: string, contents: Uint8Array): Promise; + + stop(handle: SandboxHandle): Promise; + destroy(handle: SandboxHandle): Promise; + + /** The registry — `sbx ls`. */ + list(): Promise; +} diff --git a/src/sandbox/sbx-argv.test.ts b/src/sandbox/sbx-argv.test.ts new file mode 100644 index 0000000..f74cdeb --- /dev/null +++ b/src/sandbox/sbx-argv.test.ts @@ -0,0 +1,231 @@ +import { execFileSync } from "node:child_process"; +import { + cpArgv, + createArgv, + execAgentArgv, + execArgv, + lsArgv, + lsNamesArgv, + parseLsJson, + ptyCommand, + remotePath, + rmArgv, + secretLsArgv, + secretRmArgv, + secretSetArgv, + shellJoin, + shellQuote, + stopArgv, +} from "./sbx-argv.js"; + +describe("createArgv", () => { + it("always uses --clone and the codex agent by default", () => { + expect(createArgv("t-C0-1.2", "/repo")).toEqual([ + "create", + "--clone", + "--name", + "t-C0-1.2", + "codex", + "/repo", + ]); + }); + + it("threads template + kits and can disable clone", () => { + expect( + createArgv("box", "/repo", { + agent: "claude", + clone: false, + template: "myreg/codex-snowball", + kits: ["./kit-a", "./kit-b"], + }), + ).toEqual([ + "create", + "--name", + "box", + "--template", + "myreg/codex-snowball", + "--kit", + "./kit-a", + "--kit", + "./kit-b", + "claude", + "/repo", + ]); + }); + + it("omits --template for an empty template value", () => { + expect(createArgv("box", "/repo", { template: "" })).toEqual([ + "create", + "--clone", + "--name", + "box", + "codex", + "/repo", + ]); + }); +}); + +describe("execArgv", () => { + it("wraps in `bash -c`, uses `--`, redirects stdin from /dev/null, no -i", () => { + expect(execArgv("box", "codex exec --json")).toEqual([ + "exec", + "box", + "--", + "bash", + "-c", + "codex exec --json < /dev/null", + ]); + expect(execArgv("box", "x")).not.toContain("-i"); + }); + + it("stdinFromNull:false omits the /dev/null redirect", () => { + expect(execArgv("box", "cmd", { stdinFromNull: false }).at(-1)).toBe("cmd"); + }); + + it("supports login shell, workdir, env, and the PTY mitigation", () => { + expect(execArgv("box", "cmd", { login: true })).toContain("-lc"); + expect(execArgv("box", "cmd", { workdir: "/repo" })).toEqual([ + "exec", + "-w", + "/repo", + "box", + "--", + "bash", + "-c", + "cmd < /dev/null", + ]); + expect(execArgv("box", "cmd", { env: { FOO: "bar" } })).toEqual([ + "exec", + "-e", + "FOO=bar", + "box", + "--", + "bash", + "-c", + "cmd < /dev/null", + ]); + // Under pty the TTY supplies stdin — no /dev/null redirect. + const pty = execArgv("box", "codex exec", { pty: true }); + expect(pty[pty.length - 1]).toBe(ptyCommand("codex exec")); + }); + + it("execAgentArgv quotes an agent argv into the bash -c string (+ stdin redirect)", () => { + expect(execAgentArgv("box", ["codex", "exec", "fix the bug"])).toEqual([ + "exec", + "box", + "--", + "bash", + "-c", + "codex exec 'fix the bug' < /dev/null", + ]); + }); +}); + +describe("shellQuote / shellJoin", () => { + it("leaves safe words bare and quotes the rest", () => { + expect(shellQuote("codex")).toBe("codex"); + expect(shellQuote("--json")).toBe("--json"); + expect(shellQuote("/home/u/.codex")).toBe("/home/u/.codex"); + expect(shellQuote("")).toBe("''"); + expect(shellQuote("a b")).toBe("'a b'"); + expect(shellQuote("$HOME")).toBe("'$HOME'"); + expect(shellQuote("it's")).toBe("'it'\\''s'"); + }); + + // The strong guarantee: bash must reparse shellJoin(argv) back into argv. + const reparse = (argv: string[]): string[] => { + const out = execFileSync("bash", ["-c", `printf '%s\\0' ${shellJoin(argv)}`], { + encoding: "utf8", + }); + const parts = out.split("\0"); + parts.pop(); // trailing "" after the final NUL + return parts; + }; + + it("round-trips a hostile argv table through real bash", () => { + const table: string[][] = [ + ["codex", "exec", "--json"], + ["a b", "it's", 'say "hi"'], + ["use $HOME", "back`tick`", "semi;colon", "and && or", "pipe|x"], + ["line\nbreak", "tab\there", "glob*?[x]", "-n", "--", ""], + ["fix the bug: $(rm -rf /)", "emoji 🚀", "{brace}", "~tilde"], + ]; + for (const argv of table) { + expect(reparse(argv)).toEqual(argv); + } + }); +}); + +describe("ls + parseLsJson", () => { + it("builds ls argv variants", () => { + expect(lsArgv()).toEqual(["ls", "--json"]); + expect(lsNamesArgv()).toEqual(["ls", "-q"]); + }); + + it("parses a top-level array", () => { + const json = JSON.stringify([ + { name: "t-C0-1.2", status: "running", agent: "codex", workspace: "/repo" }, + { name: "_login-tmp", status: "stopped" }, + ]); + expect(parseLsJson(json)).toEqual([ + { name: "t-C0-1.2", status: "running", agent: "codex", workspace: "/repo" }, + { name: "_login-tmp", status: "stopped", agent: undefined, workspace: undefined }, + ]); + }); + + it("parses a { sandboxes: [...] } wrapper and skips entries without a name", () => { + const json = JSON.stringify({ sandboxes: [{ name: "box" }, { status: "running" }] }); + expect(parseLsJson(json)).toEqual([ + { name: "box", status: undefined, agent: undefined, workspace: undefined }, + ]); + }); + + it("returns [] for empty output", () => { + expect(parseLsJson("")).toEqual([]); + expect(parseLsJson(" \n")).toEqual([]); + }); + + it("tolerates sbx info lines prepended before the JSON", () => { + const noisy = + 'Starting sandboxd daemon...\nDaemon started.\n{"sandboxes":[{"name":"t-C0-1.2"}]}'; + expect(parseLsJson(noisy)).toEqual([ + { name: "t-C0-1.2", status: undefined, agent: undefined, workspace: undefined }, + ]); + }); + + it("returns [] (never throws) on output with no JSON", () => { + expect(parseLsJson("Starting sandboxd daemon...\nno json here")).toEqual([]); + }); +}); + +describe("cp / stop / rm", () => { + it("addresses sandbox paths and builds cp argv", () => { + expect(remotePath("box", "/home/u/.agent-state/session")).toBe( + "box:/home/u/.agent-state/session", + ); + expect(cpArgv("box:/a", "./a")).toEqual(["cp", "box:/a", "./a"]); + }); + + it("stops one or many, removes with --force for non-interactive use", () => { + expect(stopArgv("box")).toEqual(["stop", "box"]); + expect(stopArgv("a", "b")).toEqual(["stop", "a", "b"]); + expect(rmArgv(["box"], { force: true })).toEqual(["rm", "--force", "box"]); + expect(rmArgv(["box"])).toEqual(["rm", "box"]); + }); +}); + +describe("secret (onboarding/auth)", () => { + it("lists, sets (global oauth / global stdin), and removes", () => { + expect(secretLsArgv()).toEqual(["secret", "ls"]); + expect(secretSetArgv("openai", { oauth: true })).toEqual([ + "secret", + "set", + "-g", + "--oauth", + "openai", + ]); + expect(secretSetArgv("openai")).toEqual(["secret", "set", "-g", "openai"]); + expect(secretSetArgv("openai", { global: false })).toEqual(["secret", "set", "openai"]); + expect(secretRmArgv("openai", { global: true })).toEqual(["secret", "rm", "-g", "openai"]); + }); +}); diff --git a/src/sandbox/sbx-argv.ts b/src/sandbox/sbx-argv.ts new file mode 100644 index 0000000..646f46e --- /dev/null +++ b/src/sandbox/sbx-argv.ts @@ -0,0 +1,172 @@ +const BARE_WORD = /^[A-Za-z0-9_/.:=@%+,-]+$/; + +export function shellQuote(arg: string): string { + if (arg === "") { + return "''"; + } + if (BARE_WORD.test(arg)) { + return arg; + } + // Close the quote, emit an escaped ', reopen: ' -> '\'' + return `'${arg.replace(/'/g, "'\\''")}'`; +} + +export function shellJoin(argv: string[]): string { + return argv.map(shellQuote).join(" "); +} + +export function ptyCommand(command: string): string { + return `script -qec ${shellQuote(command)} /dev/null`; +} + +export interface CreateOptions { + agent?: string; + clone?: boolean; + template?: string; + kits?: string[]; +} + +export function createArgv(name: string, repoRef: string, opts: CreateOptions = {}): string[] { + const { agent = "codex", clone = true, template, kits = [] } = opts; + const argv = ["create"]; + if (clone) { + argv.push("--clone"); + } + argv.push("--name", name); + if (template !== undefined && template !== "") { + argv.push("--template", template); + } + for (const kit of kits) { + argv.push("--kit", kit); + } + argv.push(agent, repoRef); + return argv; +} + +export interface ExecOptions { + env?: Record; + workdir?: string; + login?: boolean; + pty?: boolean; + stdinFromNull?: boolean; +} + +export function execArgv(name: string, command: string, opts: ExecOptions = {}): string[] { + const argv = ["exec"]; + for (const [key, value] of Object.entries(opts.env ?? {})) { + argv.push("-e", `${key}=${value}`); + } + if (opts.workdir !== undefined) { + argv.push("-w", opts.workdir); + } + let inner: string; + if (opts.pty === true) { + inner = ptyCommand(command); + } else if (opts.stdinFromNull === false) { + inner = command; + } else { + inner = `${command} < /dev/null`; + } + argv.push(name, "--", "bash", opts.login === true ? "-lc" : "-c", inner); + return argv; +} + +export function execAgentArgv(name: string, agentArgv: string[], opts: ExecOptions = {}): string[] { + return execArgv(name, shellJoin(agentArgv), opts); +} + +export function lsArgv(): string[] { + return ["ls", "--json"]; +} + +export function lsNamesArgv(): string[] { + return ["ls", "-q"]; +} + +export interface SbxListEntry { + name: string; + status?: string; + agent?: string; + workspace?: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function strOrUndef(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +export function parseLsJson(stdout: string): SbxListEntry[] { + const trimmed = stdout.trim(); + const start = trimmed.search(/[[{]/); + if (start < 0) { + return []; + } + let data: unknown; + try { + data = JSON.parse(trimmed.slice(start)); + } catch { + return []; + } + const items: unknown[] = Array.isArray(data) + ? data + : isRecord(data) && Array.isArray(data.sandboxes) + ? data.sandboxes + : []; + const entries: SbxListEntry[] = []; + for (const item of items) { + if (isRecord(item) && typeof item.name === "string") { + entries.push({ + name: item.name, + status: strOrUndef(item.status), + agent: strOrUndef(item.agent), + workspace: strOrUndef(item.workspace), + }); + } + } + return entries; +} + +export function remotePath(name: string, path: string): string { + return `${name}:${path}`; +} + +export function cpArgv(src: string, dst: string): string[] { + return ["cp", src, dst]; +} + +export function stopArgv(...names: string[]): string[] { + return ["stop", ...names]; +} + +export function rmArgv(names: string[], opts: { force?: boolean } = {}): string[] { + return ["rm", ...(opts.force === true ? ["--force"] : []), ...names]; +} + +export function secretLsArgv(): string[] { + return ["secret", "ls"]; +} + +export interface SecretSetOptions { + global?: boolean; + oauth?: boolean; +} + +export function secretSetArgv(service: string, opts: SecretSetOptions = {}): string[] { + const { global = true, oauth = false } = opts; + const argv = ["secret", "set"]; + if (global) { + argv.push("-g"); + } + if (oauth) { + argv.push("--oauth"); + } + argv.push(service); + return argv; +} + +export function secretRmArgv(service: string, opts: { global?: boolean } = {}): string[] { + return ["secret", "rm", ...(opts.global === true ? ["-g"] : []), service]; +} diff --git a/src/sandbox/sbx-provider.live.test.ts b/src/sandbox/sbx-provider.live.test.ts new file mode 100644 index 0000000..fb055ba --- /dev/null +++ b/src/sandbox/sbx-provider.live.test.ts @@ -0,0 +1,80 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { SandboxHandle } from "./index.js"; +import { SbxProvider } from "./sbx-provider.js"; + +/** + * Live verification of the sbx I/O seam against a real microVM. Gated + * by SBX_LIVE=1 (set by `npm run test:live`) and excluded from the default suite + * + CI — it needs Docker + the sbx daemon. Uses a credential-free `shell` + * sandbox so it covers exec/cp/$HOME/stop-restart without any agent auth. + * + * Verified manually 2026-05-31 (sbx v0.31.1): bash -c env, $HOME=/home/agent, + * cp round-trip, and lossless stop→exec auto-restart all pass. + */ + +const LIVE = process.env.SBX_LIVE === "1"; +const NAME = "sca-live-test"; + +describe.skipIf(!LIVE)("SbxProvider — live shell sandbox", () => { + const provider = new SbxProvider({ createOptions: { agent: "shell", clone: false } }); + const handle: SandboxHandle = { name: NAME }; + let workspace = ""; + + beforeAll(async () => { + workspace = mkdtempSync(join(tmpdir(), "sca-live-")); + try { + await provider.destroy(handle); // clean up any leftover from a prior run + } catch { + // not present — fine + } + await provider.create(NAME, workspace); + }); + + afterAll(async () => { + try { + await provider.destroy(handle); + } catch { + // best-effort cleanup + } + if (workspace !== "") { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + it("exec runs an agent argv via bash -c and drains stdout", async () => { + const result = await provider.exec(handle, ["echo", "hi from agent argv"]); + expect(result.stdout.trim()).toBe("hi from agent argv"); + expect(result.exitCode).toBe(0); + }); + + it("homeDir resolves $HOME", async () => { + expect((await provider.homeDir(handle)).length).toBeGreaterThan(0); + }); + + it("writeFile/readFile round-trips; a missing file reads as null", async () => { + const home = await provider.homeDir(handle); + await provider.writeFile(handle, `${home}/.agent-state/session`, "sess-live\n"); + expect(await provider.readFile(handle, `${home}/.agent-state/session`)).toBe("sess-live\n"); + expect(await provider.readFile(handle, `${home}/.agent-state/missing`)).toBeNull(); + }); + + it("getFile/putFile round-trips bytes via cp + a host temp file", async () => { + const home = await provider.homeDir(handle); + await provider.putFile(handle, `${home}/.agent-state/bin`, new TextEncoder().encode("bytes\n")); + const got = await provider.getFile(handle, `${home}/.agent-state/bin`); + expect(new TextDecoder().decode(got)).toBe("bytes\n"); + }); + + it("stop then exec auto-restarts losslessly — in-VM state survives", async () => { + const home = await provider.homeDir(handle); + await provider.stop(handle); + const result = await provider.exec(handle, ["cat", `${home}/.agent-state/session`]); + expect(result.stdout).toContain("sess-live"); + }); + + it("list() includes the sandbox", async () => { + expect((await provider.list()).some((h) => h.name === NAME)).toBe(true); + }); +}); diff --git a/src/sandbox/sbx-provider.test.ts b/src/sandbox/sbx-provider.test.ts new file mode 100644 index 0000000..f5b5692 --- /dev/null +++ b/src/sandbox/sbx-provider.test.ts @@ -0,0 +1,232 @@ +import { EventEmitter } from "node:events"; +import { readFileSync, writeFileSync } from "node:fs"; +import type { SandboxHandle } from "./index.js"; +import { type ChildLike, SbxProvider, type SpawnFn } from "./sbx-provider.js"; + +class FakeStdin { + written = ""; + ended = false; + write(chunk: string): boolean { + this.written += chunk; + return true; + } + end(): void { + this.ended = true; + } +} + +class FakeChild extends EventEmitter implements ChildLike { + stdout = new EventEmitter(); + stderr = new EventEmitter(); + stdin = new FakeStdin(); +} + +type Resp = { stdout?: string; stderr?: string; code?: number; error?: Error }; + +function fakeSpawn(responder: (args: string[]) => Resp): { + spawnFn: SpawnFn; + calls: { args: string[]; stdin: FakeStdin }[]; +} { + const calls: { args: string[]; stdin: FakeStdin }[] = []; + const spawnFn: SpawnFn = (_command, args) => { + const child = new FakeChild(); + calls.push({ args, stdin: child.stdin }); + const r = responder(args); + setImmediate(() => { + if (r.error) { + child.emit("error", r.error); + return; + } + if (r.stdout) { + child.stdout.emit("data", Buffer.from(r.stdout)); + } + if (r.stderr) { + child.stderr.emit("data", Buffer.from(r.stderr)); + } + child.emit("close", r.code ?? 0); + }); + return child; + }; + return { spawnFn, calls }; +} + +const handle: SandboxHandle = { name: "t-C0ABCDEF-1748600000.123456" }; + +describe("exec — drain, stdin close, streaming", () => { + it("wraps argv in bash -c (no -i), drains stdout+stderr, closes stdin, streams chunks", async () => { + let recorded: string[] = []; + let stdin: FakeStdin | undefined; + const spawnFn: SpawnFn = (_c, args) => { + recorded = args; + const child = new FakeChild(); + stdin = child.stdin; + setImmediate(() => { + child.stdout.emit("data", Buffer.from("Fixed ")); + child.stdout.emit("data", Buffer.from("the bug")); + child.stderr.emit("data", Buffer.from("progress\n")); + child.emit("close", 0); + }); + return child; + }; + const provider = new SbxProvider({ spawnFn }); + const chunks: string[] = []; + const result = await provider.exec(handle, ["codex", "exec", "fix the bug"], { + onChunk: (stream, chunk) => chunks.push(`${stream}:${chunk}`), + }); + + expect(result).toEqual({ stdout: "Fixed the bug", stderr: "progress\n", exitCode: 0 }); + expect(recorded).toEqual([ + "exec", + handle.name, + "--", + "bash", + "-c", + "codex exec 'fix the bug' < /dev/null", + ]); + expect(recorded).not.toContain("-i"); + expect(stdin?.ended).toBe(true); + expect(chunks).toEqual(["stdout:Fixed ", "stdout:the bug", "stderr:progress\n"]); + }); + + it("propagates a non-zero exit code without throwing (agent failures interpreted upstream)", async () => { + const { spawnFn } = fakeSpawn(() => ({ stdout: "", code: 3 })); + const provider = new SbxProvider({ spawnFn }); + expect((await provider.exec(handle, ["codex", "exec", "x"])).exitCode).toBe(3); + }); + + it("rejects when the process emits an error (e.g. sbx not found)", async () => { + const { spawnFn } = fakeSpawn(() => ({ error: new Error("ENOENT sbx") })); + const provider = new SbxProvider({ spawnFn }); + await expect(provider.exec(handle, ["codex"])).rejects.toThrow(/ENOENT/); + }); +}); + +describe("create / stop / destroy / list", () => { + it("create uses --clone + --name and returns a handle on success", async () => { + const { spawnFn, calls } = fakeSpawn(() => ({ code: 0 })); + const provider = new SbxProvider({ spawnFn }); + expect(await provider.create("t-C0-1.2", "/repo")).toEqual({ name: "t-C0-1.2" }); + expect(calls[0]?.args).toEqual(["create", "--clone", "--name", "t-C0-1.2", "codex", "/repo"]); + }); + + it("create throws on non-zero exit, surfacing stderr", async () => { + const { spawnFn } = fakeSpawn(() => ({ stderr: "name already exists", code: 1 })); + const provider = new SbxProvider({ spawnFn }); + await expect(provider.create("box", "/repo")).rejects.toThrow(/name already exists/); + }); + + it("list parses the { sandboxes: [...] } wrapper into handles", async () => { + const { spawnFn, calls } = fakeSpawn(() => ({ + stdout: JSON.stringify({ sandboxes: [{ name: "a" }, { name: "b" }] }), + })); + const provider = new SbxProvider({ spawnFn }); + expect(await provider.list()).toEqual([{ name: "a" }, { name: "b" }]); + expect(calls[0]?.args).toEqual(["ls", "--json"]); + }); + + it("stop and destroy build the right argv (rm --force for non-interactive)", async () => { + const { spawnFn, calls } = fakeSpawn(() => ({ code: 0 })); + const provider = new SbxProvider({ spawnFn }); + await provider.stop(handle); + await provider.destroy(handle); + expect(calls[0]?.args).toEqual(["stop", handle.name]); + expect(calls[1]?.args).toEqual(["rm", "--force", handle.name]); + }); +}); + +describe("execShell + SandboxFsLike", () => { + it("execShell runs `bash -c ` verbatim (no shellJoin)", async () => { + const { spawnFn, calls } = fakeSpawn(() => ({ stdout: "" })); + const provider = new SbxProvider({ spawnFn }); + await provider.execShell(handle, "find $HOME -name '*.jsonl'"); + expect(calls[0]?.args).toEqual([ + "exec", + handle.name, + "--", + "bash", + "-c", + "find $HOME -name '*.jsonl' < /dev/null", + ]); + }); + + it("homeDir trims the reported $HOME", async () => { + const { spawnFn } = fakeSpawn(() => ({ stdout: "/root" })); + expect(await new SbxProvider({ spawnFn }).homeDir(handle)).toBe("/root"); + }); + + it("homeDir caches per sandbox handle", async () => { + const { spawnFn, calls } = fakeSpawn(() => ({ stdout: "/home/agent\n" })); + const provider = new SbxProvider({ spawnFn }); + + await expect(provider.homeDir(handle)).resolves.toBe("/home/agent"); + await expect(provider.homeDir(handle)).resolves.toBe("/home/agent"); + await expect(provider.homeDir({ name: "other" })).resolves.toBe("/home/agent"); + + expect(calls.filter((call) => call.args[0] === "exec")).toHaveLength(2); + }); + + it("readFile returns content on exit 0 and null on non-zero (missing file)", async () => { + const present = new SbxProvider({ + spawnFn: fakeSpawn(() => ({ stdout: "data", code: 0 })).spawnFn, + }); + expect(await present.readFile(handle, "/home/u/.agent-state/session")).toBe("data"); + const missing = new SbxProvider({ + spawnFn: fakeSpawn(() => ({ stderr: "No such file", code: 1 })).spawnFn, + }); + expect(await missing.readFile(handle, "/home/u/.agent-state/session")).toBeNull(); + }); +}); + +describe("getFile / putFile — cp + host temp file round-trip", () => { + // A virtual sandbox FS; the fake cp moves bytes between host temp files and it. + function vfsSpawn(vfs: Map) { + return fakeSpawn((args) => { + if (args[0] !== "cp") { + return { code: 0 }; + } + const src = args[1] ?? ""; + const dst = args[2] ?? ""; + if (src.includes(":") && !dst.includes(":")) { + const content = vfs.get(src); + if (content === undefined) { + return { stderr: "no such file", code: 1 }; + } + writeFileSync(dst, content); + return { code: 0 }; + } + if (!src.includes(":") && dst.includes(":")) { + vfs.set(dst, readFileSync(src, "utf8")); + return { code: 0 }; + } + return { code: 1, stderr: "bad cp" }; + }); + } + + it("putFile writes then getFile reads the same bytes (via cp tempfiles)", async () => { + const vfs = new Map(); + const { spawnFn, calls } = vfsSpawn(vfs); + const provider = new SbxProvider({ spawnFn }); + const path = "/home/agent/.agent-state/session"; + + await provider.putFile(handle, path, new TextEncoder().encode("sess-123\n")); + const got = await provider.getFile(handle, path); + expect(new TextDecoder().decode(got)).toBe("sess-123\n"); + + // cp addressed the sandbox path as name:path on both legs + const cpCalls = calls.filter((c) => c.args[0] === "cp"); + expect(cpCalls.some((c) => c.args[2] === `${handle.name}:${path}`)).toBe(true); + expect(cpCalls.some((c) => c.args[1] === `${handle.name}:${path}`)).toBe(true); + }); + + it("writeFile mkdir -p's the parent dir before putting the file", async () => { + const vfs = new Map(); + const { spawnFn, calls } = vfsSpawn(vfs); + const provider = new SbxProvider({ spawnFn }); + await provider.writeFile(handle, "/home/agent/.agent-state/session", "abc\n"); + const shellCmds = calls + .filter((c) => c.args[0] === "exec") + .map((c) => c.args[c.args.length - 1]); + expect(shellCmds.some((cmd) => cmd?.startsWith("mkdir -p"))).toBe(true); + expect(vfs.get(`${handle.name}:/home/agent/.agent-state/session`)).toBe("abc\n"); + }); +}); diff --git a/src/sandbox/sbx-provider.ts b/src/sandbox/sbx-provider.ts new file mode 100644 index 0000000..691b50b --- /dev/null +++ b/src/sandbox/sbx-provider.ts @@ -0,0 +1,209 @@ +import { spawn as nodeSpawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { ExecCallOptions, ExecResult, SandboxHandle, SandboxProvider } from "./index.js"; +import { + type CreateOptions, + cpArgv, + createArgv, + execAgentArgv, + execArgv, + lsArgv, + parseLsJson, + remotePath, + rmArgv, + secretLsArgv, + shellQuote, + stopArgv, +} from "./sbx-argv.js"; + +/** + * SandboxProvider over the `sbx` CLI — the I/O shell. All argv + * construction lives in sbx-argv.ts; this file only spawns and drains. The + * spawn fn is injected so the streaming/stdin/exit handling is unit-testable + * without the daemon. + * + * Also provides homeDir/readFile/writeFile (structurally satisfying the router's + * SandboxFsLike) and execShell (the codex backend's SandboxShellExecutor) — no + * upward imports, just compatible method shapes. + */ + +/** Minimal child-process surface the drain loop needs (real ChildProcess fits). */ +export interface ChildLike { + stdout: { on(event: "data", listener: (chunk: unknown) => void): unknown } | null; + stderr: { on(event: "data", listener: (chunk: unknown) => void): unknown } | null; + stdin: { write(chunk: string): unknown; end(): unknown } | null; + on(event: "close", listener: (code: number | null) => void): unknown; + on(event: "error", listener: (err: Error) => void): unknown; +} + +export type SpawnFn = (command: string, args: string[]) => ChildLike; + +const defaultSpawn: SpawnFn = (command, args) => + nodeSpawn(command, args, { stdio: ["pipe", "pipe", "pipe"] }); + +export interface SbxProviderConfig { + spawnFn?: SpawnFn; + bin?: string; + createOptions?: CreateOptions; + /** `bash -lc` to source profile-level env if `-c` isn't enough. */ + login?: boolean; + /** Wrap the agent command in a PTY (codex empty-output mitigation). */ + pty?: boolean; +} + +export class SbxProvider implements SandboxProvider { + private readonly spawnFn: SpawnFn; + private readonly bin: string; + private readonly createOptions: CreateOptions; + private readonly login: boolean; + private readonly pty: boolean; + /** Per-sandbox `$HOME` cache — invariant for a sandbox's life (see homeDir). */ + private readonly homeDirCache = new Map(); + + constructor(config: SbxProviderConfig = {}) { + this.spawnFn = config.spawnFn ?? defaultSpawn; + this.bin = config.bin ?? "sbx"; + this.createOptions = config.createOptions ?? {}; + this.login = config.login ?? false; + this.pty = config.pty ?? false; + } + + /** Spawn `sbx `, drain stdout+stderr to exit, close stdin. */ + private run(argv: string[], opts: ExecCallOptions = {}): Promise { + return new Promise((resolve, reject) => { + const child = this.spawnFn(this.bin, argv); + let stdout = ""; + let stderr = ""; + child.stdout?.on("data", (chunk) => { + const text = String(chunk); + stdout += text; + opts.onChunk?.("stdout", text); + }); + child.stderr?.on("data", (chunk) => { + const text = String(chunk); + stderr += text; + opts.onChunk?.("stderr", text); + }); + child.on("error", reject); + child.on("close", (code) => resolve({ stdout, stderr, exitCode: code ?? -1 })); + // Close stdin so a headless agent can't hang waiting on it. + if (opts.stdin !== undefined) { + child.stdin?.write(opts.stdin); + } + child.stdin?.end(); + }); + } + + private async runOrThrow(argv: string[], what: string): Promise { + const result = await this.run(argv); + if (result.exitCode !== 0) { + throw new Error(`sbx ${what} failed (exit ${result.exitCode}): ${result.stderr.trim()}`); + } + return result; + } + + async create(name: string, repoRef: string): Promise { + await this.runOrThrow(createArgv(name, repoRef, this.createOptions), `create ${name}`); + return { name }; + } + + exec(handle: SandboxHandle, argv: string[], opts: ExecCallOptions = {}): Promise { + return this.run( + execAgentArgv(handle.name, argv, { + login: this.login, + pty: this.pty, + workdir: opts.cwd, + }), + opts, + ); + } + + execShell( + handle: SandboxHandle, + command: string, + opts: ExecCallOptions = {}, + ): Promise { + return this.run(execArgv(handle.name, command, { login: this.login }), opts); + } + + async stop(handle: SandboxHandle): Promise { + await this.runOrThrow(stopArgv(handle.name), `stop ${handle.name}`); + } + + async destroy(handle: SandboxHandle): Promise { + await this.runOrThrow(rmArgv([handle.name], { force: true }), `rm ${handle.name}`); + } + + async list(): Promise { + const { stdout } = await this.runOrThrow(lsArgv(), "ls"); + return parseLsJson(stdout).map((entry) => ({ name: entry.name })); + } + + /** Raw `sbx secret ls` output — drives the onboarding credential check. */ + async secretLs(): Promise { + const { stdout } = await this.runOrThrow(secretLsArgv(), "secret ls"); + return stdout; + } + + // --- SandboxFsLike (structural) --------------------------------------- + + async homeDir(handle: SandboxHandle): Promise { + // $HOME is invariant for a sandbox's lifetime, yet each lookup is a full + // `sbx exec` round-trip — and the per-thread state store resolves it on + // every read/write (~5×/turn). Cache it per sandbox so we pay that once. + const cached = this.homeDirCache.get(handle.name); + if (cached !== undefined) { + return cached; + } + const { stdout } = await this.execShell(handle, 'printf %s "$HOME"'); + // Defensive: take the last line in case sbx ever prefixes a start-up info + // line on stdout (it normally routes those to stderr). + const lines = stdout.trim().split("\n"); + const home = (lines[lines.length - 1] ?? "").trim() || "/root"; + this.homeDirCache.set(handle.name, home); + return home; + } + + /** File contents, or null if the file does not exist (non-zero `cat` exit). */ + async readFile(handle: SandboxHandle, absPath: string): Promise { + const { stdout, exitCode } = await this.execShell(handle, `cat ${shellQuote(absPath)}`); + return exitCode === 0 ? stdout : null; + } + + async writeFile(handle: SandboxHandle, absPath: string, content: string): Promise { + const dir = absPath.slice(0, absPath.lastIndexOf("/")); + if (dir !== "") { + await this.execShell(handle, `mkdir -p ${shellQuote(dir)}`); + } + await this.putFile(handle, absPath, new TextEncoder().encode(content)); + } + + // --- binary file transfer via `sbx cp` + a host temp file ------------- + + private tempPath(): string { + return join(tmpdir(), `sbx-${randomUUID()}`); + } + + async getFile(handle: SandboxHandle, path: string): Promise { + const tmp = this.tempPath(); + await this.runOrThrow(cpArgv(remotePath(handle.name, path), tmp), `cp (get) ${path}`); + try { + return await readFile(tmp); + } finally { + await rm(tmp, { force: true }); + } + } + + async putFile(handle: SandboxHandle, path: string, contents: Uint8Array): Promise { + const tmp = this.tempPath(); + await writeFile(tmp, contents); + try { + await this.runOrThrow(cpArgv(tmp, remotePath(handle.name, path)), `cp (put) ${path}`); + } finally { + await rm(tmp, { force: true }); + } + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..fd212cc --- /dev/null +++ b/src/types.ts @@ -0,0 +1,23 @@ +/** Shared types for the seams. */ + +/** A Slack thread identity: channel + root-message ts. */ +export interface ThreadId { + channel: string; + threadTs: string; +} + +/** A normalized inbound mention handed to the router. */ +export interface Mention { + thread: ThreadId; + /** ts of the triggering message — used for idempotent dispatch. */ + ts: string; + user: string; + text: string; +} + +/** One message in a thread's transcript. */ +export interface ThreadMessage { + ts: string; + user: string; + text: string; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..3276af0 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + }, + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a6b7c1b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "moduleDetection": "force", + "types": ["node", "vitest/globals"], + + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + + "verbatimModuleSyntax": true, + "isolatedModules": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..5689165 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vitest/config"; + +/** + * Default test run: offline unit tests only. Live tests (`*.live.test.ts`, + * which need the sbx daemon / Docker / creds) are excluded here and + * run via `npm run test:live` (see vitest.live.config.ts). + */ +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/dist/**", "src/**/*.live.test.ts"], + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/**/*.live.test.ts", "src/**/fixtures/**"], + }, + }, +}); diff --git a/vitest.live.config.ts b/vitest.live.config.ts new file mode 100644 index 0000000..7550219 --- /dev/null +++ b/vitest.live.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vitest/config"; + +/** + * Live lane — only `*.live.test.ts`, which exercise the real sbx daemon / Docker + * / Codex. NOT part of `npm run check` or CI; run on demand with + * `npm run test:live` once the environment is up (Tier 1, see the plan). Passes + * with no test files so the script is safe before any live tests exist. + */ +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["src/**/*.live.test.ts"], + passWithNoTests: true, + testTimeout: 120_000, + // sandbox create can pull an image on first run — give setup generous time. + hookTimeout: 300_000, + }, +});