diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff096daf96..71d70cdec9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,7 +160,7 @@ jobs: # exist during check/test; distributable assets remain frontend-build's owner. - name: Create Tauri resource directories shell: bash - run: mkdir -p dist src/mobile-web/dist + run: mkdir -p dist src/mobile-web/dist packages/dsh-acp/dist-profile - name: Install Linux system dependencies (Tauri) if: runner.os == 'Linux' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 993d6a26c0..83b31d313f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,6 +42,10 @@ The check detects: bitfun-desktop Tauri build script references this directory as a resource, so `cargo check -p bitfun-desktop` and `cargo check --workspace` fail without it) +- Missing `packages/dsh-acp/dist-profile` (fix: `pnpm run prepare:dsh-profile` + — the DeepSeek Harness bridge, referenced as a Tauri resource the same way; + `BITFUN_SKIP_DSH_PROFILE=1` writes a placeholder instead if you do not need + the bridge) - Missing sherpa-onnx prebuilt libs (the sherpa-onnx-sys build script downloads from GitHub at build time; if the download fails on poor connectivity, set `SHERPA_ONNX_LIB_DIR` to the prebuilt lib directory diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md index 6b6b268c18..72062987d1 100644 --- a/CONTRIBUTING_CN.md +++ b/CONTRIBUTING_CN.md @@ -39,6 +39,9 @@ pnpm run check:build-prereqs -- --fix # 尝试自动修复缺失的前置依赖 - 缺少 `src/mobile-web/dist`(修复:`pnpm run prepare:mobile-web` — bitfun-desktop 的 Tauri 构建脚本将该目录作为资源引用,缺失时 `cargo check -p bitfun-desktop` 和 `cargo check --workspace` 会失败) +- 缺少 `packages/dsh-acp/dist-profile`(修复:`pnpm run prepare:dsh-profile` — + DeepSeek Harness bridge,同样以 Tauri 资源方式引用;不需要该 bridge 时可用 + `BITFUN_SKIP_DSH_PROFILE=1` 写一个占位目录) - 缺少 sherpa-onnx 预编译库(sherpa-onnx-sys 构建脚本会在构建时从 GitHub 下载;若网络连通性差导致下载失败,设置 `SHERPA_ONNX_LIB_DIR` 指向 `target/sherpa-onnx-prebuilt/` 下的预编译 diff --git a/Cargo.lock b/Cargo.lock index 54d562583f..2b45fc73aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -770,11 +770,14 @@ dependencies = [ "bitfun-events", "chrono", "dashmap", + "dirs 6.0.0", "futures", "log", + "semver", "serde", "serde_json", "sha2", + "tar", "tokio", "tokio-util", "uuid", diff --git a/package.json b/package.json index 9f0970e629..805f58710f 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "type-check:skin-market": "pnpm --dir src/skin-market-web type-check", "test:skin-market": "pnpm --dir src/skin-market-web test", "prepare:mobile-web": "node scripts/mobile-web-build.cjs", + "prepare:dsh-profile": "node scripts/prepare-dsh-profile.mjs", "frontend:build-all": "node scripts/frontend-build-all.mjs", "preview": "pnpm --dir src/web-ui preview", "desktop:dev": "node scripts/dev.cjs desktop", diff --git a/packages/dsh-acp/.gitignore b/packages/dsh-acp/.gitignore new file mode 100644 index 0000000000..659e82c0fb --- /dev/null +++ b/packages/dsh-acp/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +lib/ +dist-profile/ +.sessions/ +.env diff --git a/packages/dsh-acp/NOTICE.md b/packages/dsh-acp/NOTICE.md new file mode 100644 index 0000000000..9c9b4dcba2 --- /dev/null +++ b/packages/dsh-acp/NOTICE.md @@ -0,0 +1,30 @@ +# Third-party notices + +This package is a fork of parts of [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), +which is distributed under the MIT License. Copyright (c) DeepSeek. + +| File here | Upstream source | Relationship | +| --- | --- | --- | +| `src/codec.ts` | `packages/acp/acp/src/codec.ts` (`@deepseek-ai/dsh-acp`) | verbatim copy | +| `src/bridge.ts` | `packages/acp/acp/src/index.ts` (`@deepseek-ai/dsh-acp`) | session, turn, approval, and teardown machinery kept; the `session/event` dispatch replaced | +| `src/app.ts` | `packages/examples/acp-demo/src/index.ts` (`@deepseek-ai/dsh-acp-demo`) | composition kept; the transport swapped for `src/bridge.ts` | +| `src/bin.ts` | `packages/examples/acp-demo/src/bin.ts` (`@deepseek-ai/dsh-acp-demo`) | copy with a different bin name | +| `cordis.yml` | `examples/acp-agent/cordis.yml` | trimmed for an IDE launch | + +The MIT License text ships with the upstream project; this fork carries the same +terms. + +## Why fork rather than depend + +`@deepseek-ai/dsh-acp` is automation-only by design. Its `session/event` handler +publishes committed assistant text and nothing else: + +> Emit only committed assistant text. Raw chunks, reasoning, tools, plans, +> titles, and retry markers are presentation or trace data and stay off the +> automation wire. + +There is no configuration flag that changes this, so an IDE client sees no tool +cards, no reasoning, and no plan — and receives permission requests for tool +calls it was never told about. This fork exists to publish exactly that withheld +data. Upstreaming it as a verbosity level in `@deepseek-ai/dsh-acp` remains the +better long-term outcome. diff --git a/packages/dsh-acp/README.md b/packages/dsh-acp/README.md new file mode 100644 index 0000000000..0f92460651 --- /dev/null +++ b/packages/dsh-acp/README.md @@ -0,0 +1,132 @@ +# @bitfun/dsh-acp + +An Agent Client Protocol server for [DeepSeek Harness](https://github.com/deepseek-ai) +(`dsh`), written for an IDE rather than for automation. + +The harness ships no ACP entry point of its own. The published +`@deepseek-ai/dsh-acp` is an automation surface: it deliberately withholds tool +calls, reasoning, and mode selection, because a script does not need to watch an +agent think. An IDE needs exactly those, so BitFun ships this bridge and runs it +inside the harness the user already installed. + +## For users + +Three steps, and BitFun stores none of your DeepSeek configuration: + +1. Install the harness — `npm install -g @deepseek-ai/dsh` (BitFun's agent + settings page has a one-click button that runs the same thing). It is a Node + program and needs **Node 20.12 or newer** — its own toolchain targets Node 22 + LTS. dsh declares no `engines`, so npm installs it onto an older Node without + complaint and it then fails to boot with a `SyntaxError` about + `util.parseEnv`; BitFun checks the version up front and says so instead. +2. Configure a model and an API key inside dsh, e.g. through `dsh web`'s Models + page. That writes `~/.dsh/settings.yaml` and `~/.dsh/.credentials.yaml`. +3. Pick **DeepSeek Harness** in BitFun and start a session. + +The model and the key stay where you put them. This bridge reads them through +the harness's own `dsh-settings-file`, `dsh-credentials-local`, and +`dsh-agent-default-model` services, so switching models in dsh switches them in +BitFun too. BitFun writes no DeepSeek credentials of its own. + +## How BitFun launches it + +BitFun runs `dsh --profile bitfun-acp`. A dsh profile is just a directory under +`$DSH_HOME/profiles/`, and BitFun materializes this one on first use — see +`src/crates/interfaces/acp/src/client/dsh_profile.rs`. + +``` +$DSH_HOME/profiles/bitfun-acp/ + package.json dsh.profile.bundles: [] — an empty root, so the + harness's default tool rows cannot leak into a + preset that asked to be minimal + cordis.patch.yml generated from cordis.yml; the whole file as one + `insert:` layer over that empty root + lib/** the compiled bridge + presets/** the modes the session offers + node_modules/ only what the harness closure does NOT already carry + .bitfun-bridge.json build stamp; BitFun re-syncs when it changes +``` + +Everything else resolves out of the user's own dsh: on every launch the harness +symlinks its entire dependency closure into `$DSH_HOME/profiles/node_modules`, +which ordinary Node parent-directory lookup finds from inside the profile. That +is why this package vendors almost nothing and never installs a second copy of +the harness. + +A remote workspace works the same way: BitFun packs the profile above into a tar +stream and extracts it into `$DSH_HOME/profiles/bitfun-acp/` on that host over +the session's own transport — no SFTP, so container connections are covered too +— and skips the upload when the stamp there already matches. `dsh` itself, the +models, and the key are the remote host's, exactly as they are locally. Steps 1 +and 2 above therefore have to have been done on that host — including the Node +version, which the same probe checks — and BitFun says so if they were not. + +## Reopening a conversation + +The bridge advertises ACP's `loadSession` capability, so closing a session and +clicking it again reopens the one that was there instead of quietly starting a +blank one. Without it a client has only `session/new` to fall back on, and the +reopened conversation loses its history, its context, and the mode it ran under +— it comes back under the roster default and switchable again. + +`session/load` resumes the stored session out of the harness's own persistence +(`$DSH_HOME/acp-sessions///`), replays its events to the +client as `session/update` notifications, and answers with the session's mode. +Three consequences worth knowing: + +- **The stored mode wins over the roster default.** Which preset a session ran + under is read back from its own log, so a conversation started in `minimal` + reopens in `minimal` however the default has moved since. +- **A conversation that has started comes back locked.** The mode picker shrinks + to the one mode in force, because the composition is already baked into the + transcript — the same rule a live session follows after its first turn. +- **A session belongs to the directory it was created in.** Loading it against + another `cwd` is refused rather than answered with a session whose sandbox + boundary points somewhere else. + +`node scripts/smoke.mjs --load ` drives exactly this path against a +real installation; the id is a directory name under the path above, and `--cwd` +has to name the workspace the session was created in. + +## Development + +```sh +npm ci # the pinned harness toolchain +npm run build # tsc -> lib/ +node scripts/build-profile.mjs # lib/ + presets -> dist-profile/ +node scripts/smoke.mjs # drive the profile over real ACP +npm test # vitest +``` + +`pnpm run prepare:dsh-profile` from the repository root does the install and +both build steps, and Tauri ships `dist-profile/` as a resource. A failure +there fails the desktop build: an app that silently ships no bridge is +indistinguishable from a working one until a user starts a DeepSeek session. +`BITFUN_SKIP_DSH_PROFILE=1` opts out on purpose. + +Every `@deepseek-ai/*` dependency is pinned to the **`0.1.0-rc.6`** train — the +set npm's `next` dist-tag points at. Half of these packages still carry a +`latest` of `0.0.1-rc.1`, so an unpinned install mixes two trains and disagrees +with itself; that is worth knowing before loosening a range. They are +`devDependencies` because they are compile-time only: at runtime the profile +resolves them from the user's own dsh. + +This package is **not** a pnpm workspace member and carries its own +`package-lock.json`, so installing the harness is the desktop bundle build's +cost rather than every contributor's `pnpm install`. + +To work against a local [deepseek-harness](https://github.com/deepseek-ai) +checkout instead of the pinned versions, run `node scripts/link-local-dsh.mjs` +— it symlinks the checkout into `node_modules`, and the build then leaves that +tree alone. + +## Layout + +| Path | What it is | +| --- | --- | +| `src/app.ts` | the cordis plugin: config schema and ACP stdio transport | +| `src/bridge.ts` | session lifecycle, prompts, tool calls, permissions | +| `src/codec.ts` | harness events ⇄ ACP notifications | +| `src/tool-view.ts` | how a tool call is titled and rendered | +| `cordis.yml` | the composition; the profile patch is generated from it | +| `presets/` | the modes a session can switch between | diff --git a/packages/dsh-acp/cordis.yml b/packages/dsh-acp/cordis.yml new file mode 100644 index 0000000000..1232076c72 --- /dev/null +++ b/packages/dsh-acp/cordis.yml @@ -0,0 +1,195 @@ +# The IDE-facing ACP server composition — the HOST plane. +# +# Trimmed from deepseek-harness/examples/acp-agent/cordis.yml: the snapshot +# harness hooks, the Claude/Codex hook bridges (they need a hooks.json that has +# no meaning for an IDE launch), and the workflow/ralph/fork tools are dropped. +# +# What a session's MODEL sees — its persona, its tools, its prompt sections — +# is not here. Those rows live in ./presets//agent.cordis.yml, one +# composition per mode, and the client picks one per session through the +# `mode` session config option. A preset layer can only ADD registrations, so anything +# model-facing left in this file would appear in every mode and make the +# minimal one a lie. What stays here is what a preset must not own: the model +# route, the sandbox and approval stack, the registries the preset rows resolve, +# persistence, and the transport. +# +# Stdout carries ACP JSON-RPC, so this tree has no console logger and no HMR. +# +# WHICH model runs and WHICH key it uses are not decided here. Both come from +# the harness the user already installed and configured: `$DSH_HOME/settings.yaml` +# (what dsh's web Models page writes) and `$DSH_HOME/.credentials.yaml`, read +# through the two provider rows below. Nothing about an account is stored in +# this repository, and BitFun never asks for a key of its own. + +# User-settings document ($DSH_HOME/settings.yaml, hot-reloaded): its +# `llm-deepseek:` / `llm-pi-ai:` sections override the adapter rows below +# without a restart. Same row as the dsh-base bundle mounts. +- id: settings + name: '@deepseek-ai/dsh-settings-file' + +# Credential sources: inherited environment over the managed +# $DSH_HOME/.credentials.yaml, with project and user .env fallbacks. Adapters +# resolve references per request; nothing is materialized into this process's +# environment. A user who signed in through dsh is already signed in here. +- id: credentials + name: '@deepseek-ai/dsh-credentials-local' + +# The model a session starts on when the client names none. The values here are +# only the floor: a saved selection in the settings document wins, which is how +# "pick your model in dsh" carries into an IDE session. +- id: agent-default-model + name: '@deepseek-ai/dsh-agent-default-model' + config: + provider: deepseek-official + model: deepseek-v4-flash + +# The DeepSeek adapter: full thinking at max effort on every request, so the +# bridge has reasoning to publish as agent_thought_chunk. No key, endpoint, or +# model list is inlined — all three resolve per request from the settings +# document over this entry, with the key coming from the credential store. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + thinking: enabled + reasoningEffort: max + +# The pi-ai multi-provider twin, mounted dormant: zero routes until an +# `llm-pi-ai:` settings section supplies provider profiles, then those routes +# register live. Composed so that a user who configured a non-DeepSeek provider +# in dsh finds it here too. +- id: llm-pi-ai + name: '@deepseek-ai/dsh-llm-pi-ai' + +# Bash AND the filesystem tools are confined to the workspace; anything wider +# asks. DSH_PERMISSION_MODE overrides the default for a deployment that wants +# read-only or full access. +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: !!js "process.env.DSH_PERMISSION_MODE ?? 'workspace-write'" + workspaceRoot: !!js process.cwd() + +# Managed child-process groups for the bash executor and the ripgrep-backed +# discovery tools. +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-bash-sandbox' + config: + timeoutMs: 60000 + +# The managed `DSH_*` environment every model shell call inherits. Host-plane +# because it is injected — resolution happens before any session exists — and +# because both the standard and minimal shells read the one registry. +- id: shell-env + name: '@deepseek-ai/dsh-shell-env' + +# `ask` is the whole point of running over ACP rather than the SDK's JSON-RPC +# transport: each question travels to the client as session/request_permission, +# attached to a tool call the client has already been shown. +- id: approval + name: '@deepseek-ai/dsh-user-approval' + config: + policy: !!js "process.env.DSH_PERMISSION_MODE === 'danger-full-access' ? 'never' : 'ask'" + +# The app: agent spine + preset roster + JSONL persistence + the IDE protocol +# bridge. Loaded from source; the Loader resolves this specifier against this +# file's directory, and the roster's root against the package directory. +# +# Every spine gate below is off because the preset owns that half: the spine +# keeps its registries (tools, skills, goals, jobs, agents, loop) and hands the +# model-facing plugins to ./presets. `persona` is likewise absent — each preset +# carries its own through `@deepseek-ai/dsh-persona`. +# `provider`/`model` are deliberately absent: each session starts on +# `agent-default-model`'s live selection, so the model chosen in dsh is the +# model an IDE session runs. Sessions live under the harness home rather than +# beside whatever project happens to be open — an ACP client launches this +# adapter with the USER's workspace as the working directory. +- id: acp-agent + name: './src/app.ts' + config: + reasoning: true + persistenceRoot: !!js "process.env.DSH_ACP_SESSIONS_ROOT ?? dshHomePath('acp-sessions')" + defaultPreset: !!js "process.env.DSH_ACP_PRESET ?? 'standard'" + workspaceContext: false + toolBash: false + toolJobs: false + goals: false + skills: + enabled: false + +# Replay-aware request pressure; the routed adapter supplies model capacity. +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + +# Summarize an older range after measured pressure or a canonical provider +# overflow. Ratios scale against the routed model's context window. +- id: compaction-basic + name: '@deepseek-ai/dsh-compaction-basic' + config: + thresholdRatio: 0.8 + retainRatio: 0.08 + maxTokens: 8192 + compactionRetries: 1 + +# Projection registry backing the subagent catalog surfaces. +- id: session-projection + name: '@deepseek-ai/dsh-session-projection' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn-in-process + name: '@deepseek-ai/dsh-subagent-spawn-in-process' + config: + providerName: spawn + +# Host-plane, unlike the delegation TOOLS in ./presets: this registers a +# continuable setup on the subagent singleton rather than a tool an agent calls, +# and the setup list is not scope-aware — one copy per mounted preset would +# register `report` more than once and throw. +- id: tool-subagent-report + name: '@deepseek-ai/dsh-tool-subagent-report' + +# The skill catalog and its local-root discovery. Whether a session's model can +# CALL them is the preset's choice (`@deepseek-ai/dsh-tool-skill`). +- id: skills + name: '@deepseek-ai/dsh-skill' + +- id: skill-filesystem + name: '@deepseek-ai/dsh-skill-filesystem' + +# Persisted same-session goals: the service and its round driver are host-plane +# (the driver injects `agents`/`sessions`, which resolve before any session +# exists); the preset contributes `@deepseek-ai/dsh-tool-goal`. +- id: goals + name: '@deepseek-ai/dsh-goal' + +- id: goal-round-driver + name: '@deepseek-ai/dsh-goal-round-driver' + +# The TypeScript code runtime the `code` preset presents its catalog through. +# Composed here so that preset fails at mount naming its row, rather than at the +# model's first `run_code`. The worker-thread plugin IS the `codeRuntime` +# service (it extends the base class), so composing `dsh-code-runtime` beside it +# would register the service twice and fail the boot. +- id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker-thread' + +# Identical repeat calls trigger advisory context, never a block. +- id: repeat-tool-reminder + name: '@deepseek-ai/dsh-repeat-tool-reminder' + +# The filesystem stack rides the SAME sandbox policy as bash; read-before-edit +# composes orthogonally on top. +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + config: + cwd: !!js process.cwd() + +- id: fs-observation-policy + name: '@deepseek-ai/dsh-fs-observation-policy' diff --git a/packages/dsh-acp/package-lock.json b/packages/dsh-acp/package-lock.json new file mode 100644 index 0000000000..b9ddaa86cb --- /dev/null +++ b/packages/dsh-acp/package-lock.json @@ -0,0 +1,5759 @@ +{ + "name": "@bitfun/dsh-acp", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@bitfun/dsh-acp", + "version": "0.0.1", + "license": "MIT", + "dependencies": { + "@agentclientprotocol/sdk": "0.25.1", + "@deepseek-ai/dsh-agent-spine-demo": "0.1.0-rc.6" + }, + "bin": { + "dsh-acp-ide": "lib/bin.js" + }, + "devDependencies": { + "@deepseek-ai/cordis": "4.0.1", + "@deepseek-ai/cordis-plugin-loader": "1.0.2", + "@deepseek-ai/dsh-agent": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-default-model": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-instructions": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-loop": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-loop-testkit": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-presets": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-tool-presentation": "0.1.0-rc.6", + "@deepseek-ai/dsh-app-boot": "0.1.0-rc.6", + "@deepseek-ai/dsh-bash-sandbox": "0.1.0-rc.6", + "@deepseek-ai/dsh-code-runtime": "0.1.0-rc.6", + "@deepseek-ai/dsh-code-runtime-worker-thread": "0.1.0-rc.6", + "@deepseek-ai/dsh-compaction-basic": "0.1.0-rc.6", + "@deepseek-ai/dsh-credentials-local": "0.1.0-rc.6", + "@deepseek-ai/dsh-fs-observation-policy": "0.1.0-rc.6", + "@deepseek-ai/dsh-fs-sandbox": "0.1.0-rc.6", + "@deepseek-ai/dsh-goal": "0.1.0-rc.6", + "@deepseek-ai/dsh-goal-round-driver": "0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "0.1.0-rc.6", + "@deepseek-ai/dsh-llm-deepseek": "0.1.0-rc.6", + "@deepseek-ai/dsh-llm-pi-ai": "0.1.0-rc.6", + "@deepseek-ai/dsh-persona": "0.1.0-rc.6", + "@deepseek-ai/dsh-repeat-tool-reminder": "0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-local": "0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "0.1.0-rc.6", + "@deepseek-ai/dsh-session": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-checkpoint-policy": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence-jsonl": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-query-sqlite": "0.1.0-rc.6", + "@deepseek-ai/dsh-settings-file": "0.1.0-rc.6", + "@deepseek-ai/dsh-shell-env": "0.1.0-rc.6", + "@deepseek-ai/dsh-skill": "0.1.0-rc.6", + "@deepseek-ai/dsh-skill-filesystem": "0.1.0-rc.6", + "@deepseek-ai/dsh-subagent": "0.1.0-rc.6", + "@deepseek-ai/dsh-subagent-spawn-in-process": "0.1.0-rc.6", + "@deepseek-ai/dsh-subprocess-local": "0.1.0-rc.6", + "@deepseek-ai/dsh-terminal": "0.1.0-rc.6", + "@deepseek-ai/dsh-terminal-bash": "0.1.0-rc.6", + "@deepseek-ai/dsh-token-meter": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-bash": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-bash-persistent": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-fs": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-fs-search": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-goal": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-jobs": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-skill": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-str-replace-editor": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-subagent": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-subagent-control": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-subagent-report": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-todo": "0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "0.1.0-rc.6", + "@deepseek-ai/dsh-user-approval": "0.1.0-rc.6", + "@deepseek-ai/schemastery": "3.18.1", + "tsx": "4.20.3", + "typescript": "5.9.2", + "vitest": "3.2.4" + }, + "engines": { + "node": "^22.19.0 || >=24.0.0" + } + }, + "node_modules/@agentclientprotocol/sdk": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.25.1.tgz", + "integrity": "sha512-jx2rF3bdpGwZ75Q/meyEDLLbYmbtxk82Uh9hDCdxDvcEedBnNSF5hZAnL/kJR5VNz56JqwOmqnAqasC84MwwkQ==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", + "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@aws-sdk/xml-builder": "^3.972.38", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz", + "integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz", + "integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.0.tgz", + "integrity": "sha512-ssHIZsadPUA3lGdnoByxfnjtb9xPYQLvdfJRLKIwxOoa6tO1suG4sLFSsgd7D/CsvYd8QbBIuKTImuJha5l6aQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz", + "integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-login": "^3.972.75", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz", + "integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz", + "integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-ini": "^3.973.13", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz", + "integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz", + "integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/token-providers": "3.1108.0", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", + "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz", + "integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.32.tgz", + "integrity": "sha512-rlbmsMG7ZNgrVhWSqqXpq6y9hfiREyzCg3CNTk9UK+AoP7+65kOkqpWmqwLfV1UrRSHATdLnZF2rt9ZTUxYQJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.27.tgz", + "integrity": "sha512-M7Ay1VpBpf/YFfic9kkjwE3wyCh4G0gEM4RypRXYm7aPjyfqi+D8FEYMR2E3IqbvN+qi2rEFYAiwWL0XHtQYdQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.50.tgz", + "integrity": "sha512-gdcWRbmIf1dWA/prf44Bnnzgqj+AbsXX2yfhZhOQLwSm7NfKIYPmkRlPqP0CTepHzjxMIBdWBDdtQB+Y/dFUeg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz", + "integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.11.0.tgz", + "integrity": "sha512-ssHIZsadPUA3lGdnoByxfnjtb9xPYQLvdfJRLKIwxOoa6tO1suG4sLFSsgd7D/CsvYd8QbBIuKTImuJha5l6aQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz", + "integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", + "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.9.tgz", + "integrity": "sha512-wB/ho7pTJKqWz3WYDt2ZWDWI8bxQpN/xwf+5ZQ1zWaj+HDY9B8Fn434i6qZ6j6ZG3aCiIJtZaQVqwajx5xYsQA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", + "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@deepseek-ai/cordis": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis/-/cordis-4.0.1.tgz", + "integrity": "sha512-YBdskTU2Po1kru3GgcUWUbkTsPMA9LkSQDAY8rBkFJeajdgcQad3QPJZE26JyK99Xb6HaASvoXg2DSUTeN/0Nw==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.2", + "@standard-schema/spec": "^1.1.0" + }, + "bin": { + "cordis": "bin.js" + }, + "peerDependencies": { + "@deepseek-ai/cordis-plugin-include": "^1.0.6", + "@deepseek-ai/cordis-plugin-loader": "^1.0.2" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis-plugin-include": { + "optional": true + }, + "@deepseek-ai/cordis-plugin-loader": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/cordis-plugin-group": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis-plugin-group/-/cordis-plugin-group-1.0.1.tgz", + "integrity": "sha512-E1NThkFB3jn3TCqa6Oc++1zQHqLejF3W2wDwv2BlL3UEmgtBXL1K1j1koc+j676RuiOXtJkUJlPcOntRGKLWBQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/cordis-plugin-loader": "^1.0.2" + } + }, + "node_modules/@deepseek-ai/cordis-plugin-include": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis-plugin-include/-/cordis-plugin-include-1.0.6.tgz", + "integrity": "sha512-i1VXrZCbv6tk/iUgedCNjrxxArbWT3IvRZGB5sdqJ3ectnihivXXQbRZ8JJ73DSmAPvlMGmrbtjFAfm10yvXRg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.2", + "js-yaml": "^4.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/cordis-plugin-loader": "^1.0.2" + } + }, + "node_modules/@deepseek-ai/cordis-plugin-loader": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis-plugin-loader/-/cordis-plugin-loader-1.0.2.tgz", + "integrity": "sha512-RIW9hoVyhYDWdCI9BsvtZccPde1ECLC4OAxupwowGTak78vwVTVdb3HezTSOK1Y1/Ax3Ru0LA1pYOB04CnTxIQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "node-addon-require-builtin": "^0.1.4" + }, + "peerDependenciesMeta": { + "node-addon-require-builtin": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/cordis-plugin-timer": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cordis-plugin-timer/-/cordis-plugin-timer-1.1.3.tgz", + "integrity": "sha512-IOkey1VNmJwYa5U8bksyMOnM7mtirqjjbWLr3xA8QybLMK0sMooG3a6iJwtvd8P3MFwo3FQibEg+JPixp37MEw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1" + } + }, + "node_modules/@deepseek-ai/cosmokit": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.2.tgz", + "integrity": "sha512-muBOKtSrUKU5m/xpq8ZXWL6hQ/jgd4PhU2PqH97bcxIiLEJfNwZOGQEx4t/aS/GgxRAR+ra9pMHPMtTHU4sqqA==", + "license": "MIT" + }, + "node_modules/@deepseek-ai/dsh-agent": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent/-/dsh-agent-0.1.0-rc.6.tgz", + "integrity": "sha512-vtqq2pWTrzn0dKfj5kREZRpP82AwtGjGx9V1lYnKvF+Uc/a8zyWbSvjDE7V1d3YQAQJzs2cWO31hURWDekDXIA==", + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-agent-default-model": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-default-model/-/dsh-agent-default-model-0.1.0-rc.6.tgz", + "integrity": "sha512-5Fl15p5mHVMlp69Ea0qeS81ej1Bt9vSJtCed+gjvzkKzv5y3VbJQ8PPBV34F1rTSyhTtjj5Q7TuDugQI5ZpjfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-agent-instructions": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-instructions/-/dsh-agent-instructions-0.1.0-rc.6.tgz", + "integrity": "sha512-RCd3UWJI7b3X6EUnxnqwOFiMWh1PzRRg9GrVt4G0tVNDIxeYX+RF2FTbOrslIOkOCZxG3BlI2M/bZ6yQmB4Vwg==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-fs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-agent-loop": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-loop/-/dsh-agent-loop-0.1.0-rc.6.tgz", + "integrity": "sha512-yShuKIMW360H14L4y13j3gz3Ix1s/3lwEEpfJW4hnFAE09h9Z4yJA7UfTQmQdzMRMnuj4uWFPw0e0BbapnkFuw==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-agent-loop-testkit": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-loop-testkit/-/dsh-agent-loop-testkit-0.1.0-rc.6.tgz", + "integrity": "sha512-VOjQp3OdEhiQuwruaXdMBchZ/y/8pw5sGXhKB6MmMHxsydsmnm4ew5IISwtopufx419/OomRzijkd85glpG8Vw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-agent-presets": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-presets/-/dsh-agent-presets-0.1.0-rc.6.tgz", + "integrity": "sha512-LtDz0TYE7YNhJOhMDZ8s45xQG57bWF4F1SN3tjYDosaBdINIRTxJ0O80BC+KeliqFDw071ZNOzTUEelwEAIx7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "js-yaml": "^4.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/cordis-plugin-include": "^1.0.6", + "@deepseek-ai/cordis-plugin-loader": "^1.0.2", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-atomic-write": "^0.1.0-rc.6", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-agent-spine-demo": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-spine-demo/-/dsh-agent-spine-demo-0.1.0-rc.6.tgz", + "integrity": "sha512-urMS7CReaddhybJZCaPA8oI4VbD/A+62mLTOb2E4b8EKgZ55sMsOGfpkMZmFIs9nqJqxdFUmcxPXWKVMVtpb5w==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/cordis-plugin-timer": "^1.1.3", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-agent-instructions": "^0.1.0-rc.6", + "@deepseek-ai/dsh-agent-loop": "^0.1.0-rc.6", + "@deepseek-ai/dsh-goal": "^0.1.0-rc.6", + "@deepseek-ai/dsh-goal-round-driver": "^0.1.0-rc.6", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-jobs-local": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm-retry": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-title": "^0.1.0-rc.6", + "@deepseek-ai/dsh-shell-env": "^0.1.0-rc.6", + "@deepseek-ai/dsh-skill": "^0.1.0-rc.6", + "@deepseek-ai/dsh-skill-filesystem": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tool-bash": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tool-goal": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tool-jobs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tool-skill": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-agent-tool-presentation": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent-tool-presentation/-/dsh-agent-tool-presentation-0.1.0-rc.6.tgz", + "integrity": "sha512-Ysg45g0qnteB6w7krUBRXQ6SFbc8L+laPZTS2y15FFTLueruJaANBLIgnorEtj2bIJYF2Lq0fKH5v0wPNNrlXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-anonymous-user-id": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-anonymous-user-id/-/dsh-anonymous-user-id-0.1.0-rc.6.tgz", + "integrity": "sha512-+K/+tQzuDlmvKhoudB7qVMJMNF/p1+GTRQkkPA3JHIQM5yHj7AUYY8oMLROTvUivs8XWtkNyEP1/c6nPwrHazQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-app-boot": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-app-boot/-/dsh-app-boot-0.1.0-rc.6.tgz", + "integrity": "sha512-97Xb2wB0cuFMmbvW7/95pfMTyXwUNQxW/ROx19OdjS6PgHQNA28ZADZ19FgZmofguxzWuDD0lJPc1SWkSGLYcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-yaml": "^4.2.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/cordis-plugin-group": "^1.0.1", + "@deepseek-ai/cordis-plugin-hmr": "^1.0.16", + "@deepseek-ai/cordis-plugin-include": "^1.0.6", + "@deepseek-ai/cordis-plugin-loader": "^1.0.2", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis-plugin-hmr": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/dsh-atomic-write": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-atomic-write/-/dsh-atomic-write-0.1.0-rc.6.tgz", + "integrity": "sha512-Ot/2aygDzWuN4ypUkDJA6MpSlGbcvzzKnWAms0lg+2WUtPZr7O0cGb83K3TEO2NoAu0LJfVxlbo8Eya3tDaFtw==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-attachment": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-attachment/-/dsh-attachment-0.1.0-rc.6.tgz", + "integrity": "sha512-3P6N17NQ8jqSQGzeCs+svCIqArU8oq0YmgEAo+axN9aVuUDferWU4DLRSX59UGpmyldX4LQn81toA+c+DqMcHg==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-bash-local": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-bash-local/-/dsh-bash-local-0.1.0-rc.6.tgz", + "integrity": "sha512-YAc5W9uR7sN1Rrobp6fQlCaOdHqCWatTktV68UsoDuOJs5eM3nWEUUUVN8jdZCIDZhktT5hqbhe3+DzOX3Xg1w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6", + "@deepseek-ai/dsh-shell": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-bash-sandbox": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-bash-sandbox/-/dsh-bash-sandbox-0.1.0-rc.6.tgz", + "integrity": "sha512-TNLRriAjpEUrJplrI0BmZtAtntIkV5usYiKylJGPlGUfWWnJkLR0zL9gioyXW3kncHBVSnb+YTuf+7FnDVeVHQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-bash-local": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6", + "@deepseek-ai/dsh-shell": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-brand": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-brand/-/dsh-brand-0.1.0-rc.6.tgz", + "integrity": "sha512-E8j9Nby24qP4rfrdcfc7bpt1CHpGT3tYmycOJJkEOH4ptIdT1m2ro9nmnSd5CWYukTr64A77vjm2WGqHRI92UA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-code-runtime": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-code-runtime/-/dsh-code-runtime-0.1.0-rc.6.tgz", + "integrity": "sha512-aw8D4IOeMo11A3uxQeE4LFoW3bvaQnVkGFqqtS+lsINDARrOCJHXLUgecoKpys+Lc5erZZ4UQDnymJmL4OCcKA==", + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-code-runtime-worker-thread": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-code-runtime-worker-thread/-/dsh-code-runtime-worker-thread-0.1.0-rc.6.tgz", + "integrity": "sha512-I2YX2ZSeQnQrSJGffM2upL+7y7vGsLwKv478gesusgYtT0kH/MOcszO9ZpnV8sIqvlkgzvWgBhXzniGHWh40ZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-code-runtime": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-commands": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-commands/-/dsh-commands-0.1.0-rc.6.tgz", + "integrity": "sha512-dq1GZmGTPXEGG01ksZ6Jj6DxAkzAbg+nepvfnM/P74d6EUcsFsEy7CfS18n0VJ6/2Wp0xOWsQyyuFElqbynO7w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-compaction": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-compaction/-/dsh-compaction-0.1.0-rc.6.tgz", + "integrity": "sha512-Uu8qgrHom13gdwwxAnqmNuWM8MqafkRlhjQMMJYmaMVVxjEUewSJ6lwtMbUEBR5ViLsfv4pTm5TnhxNCjedBUw==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-commands": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-compaction-basic": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-compaction-basic/-/dsh-compaction-basic-0.1.0-rc.6.tgz", + "integrity": "sha512-sqfFPBy1SqsOmaWro/GOAcDkrryjsmJLU/M+V4HZakoE7hLyGVhMElrRG5E5OlplIl2Akntc+YYwX36kDfEItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-commands": "^0.1.0-rc.6", + "@deepseek-ai/dsh-compaction": "^0.1.0-rc.6", + "@deepseek-ai/dsh-compaction-tool-result-pruner": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-token-meter": "^0.1.0-rc.6" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-compaction-tool-result-pruner": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/dsh-credentials": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-credentials/-/dsh-credentials-0.1.0-rc.6.tgz", + "integrity": "sha512-zyYRs3A9gxfZjZfONzJdMhM0Gzbslpha+FGYkLDRAozgPj0N7mZdE+Qflx2V4WNrCnsSjuvOW0HFBxWtSRUTUw==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-credentials-local": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-credentials-local/-/dsh-credentials-local-0.1.0-rc.6.tgz", + "integrity": "sha512-DSDcC1i8hd3Ynyr6MTPFoEdAsxcmZSLo68IPBPvaPGHZ+rCX8XDAjghWvXrNkLtkjms0ngNVPJS3rNMRcJFq0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "chokidar": "^4.0.3", + "yaml": "^2.9.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-atomic-write": "^0.1.0-rc.6", + "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-fs": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs/-/dsh-fs-0.1.0-rc.6.tgz", + "integrity": "sha512-OTkwb4QsZgmjtA/8ZEPh1FapmrBr3N989/G4Wmo1JkAvKbMxkYty6LxjckOawTTz7GJTfUoZCrw9uopDfIMMNw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-fs-local": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs-local/-/dsh-fs-local-0.1.0-rc.6.tgz", + "integrity": "sha512-zP0OW474s7tvzEVQnBBTGL6Jrv/YyQXt4nswIbc3Aqr9JyHSfhZ/5IJoipZqfYXLS3ISWMhrF2npRg8L1NGsIQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "koffi": "^3.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-fs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-fs-observation-policy": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs-observation-policy/-/dsh-fs-observation-policy-0.1.0-rc.6.tgz", + "integrity": "sha512-EiBMACBVmVEs0fDFkbVH6OwXrVDp9GrTG8c62vGBfuljZvRNv6yVwS+SDblboJrIPcp67axxHRVPOEy7z1CUKQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-fs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-fs-sandbox": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs-sandbox/-/dsh-fs-sandbox-0.1.0-rc.6.tgz", + "integrity": "sha512-NLUeuZkeQVNPIpUute6TW4Ts1et6XxLkQIOxunL5T9mXwizp+tcdCeNjBW6XMiG6gvpECA+IA3amFCvbVSXtXw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-fs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-fs-local": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-goal": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-goal/-/dsh-goal-0.1.0-rc.6.tgz", + "integrity": "sha512-cONQOvFqZmjtw5HSgbXJ5/8rZ2gHqdf69ONhH21cLFu5jJKVy9YbLt42SlD2sdGMv/co9nN8Kt9bWQiZ/96VbQ==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection": "^0.1.0-rc.6", + "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-goal-round-driver": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-goal-round-driver/-/dsh-goal-round-driver-0.1.0-rc.6.tgz", + "integrity": "sha512-+tpbRXZ3nq/C2btBxyFC88RU0IC0H39JY1WyL07fq3j24AWS5jZb9/05gEJPyoyaIDkV/Li4dJVCdJH4VaF9Fw==", + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-goal": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-home-paths": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-home-paths/-/dsh-home-paths-0.1.0-rc.6.tgz", + "integrity": "sha512-gIiUBmqB3L8inFr+hvjZv2/i6EVJOHIHCHg7bBFVhcl4HDK94+8wUCXTLGy80tcuISzIQiIaLuJq/NhSmV9Amw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-invariants": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-invariants/-/dsh-invariants-0.1.0-rc.6.tgz", + "integrity": "sha512-WfEfOi99a4cpOugRAHTBSTnesLieu3ist1q9PXDXFBHX++K1rAl9+sB7YrdnbB8LH0UOY532gS9xJUYU6w0SLw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1" + } + }, + "node_modules/@deepseek-ai/dsh-jobs": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-jobs/-/dsh-jobs-0.1.0-rc.6.tgz", + "integrity": "sha512-fmyvSOVsNObmRnciuH57ZntuCSb0gflRleCeQx1ToGHRoGrR4Ndnstx33SuIXUQt6OSUhD2w2WKQTReXogpnSQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-jobs-local": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-jobs-local/-/dsh-jobs-local-0.1.0-rc.6.tgz", + "integrity": "sha512-vnCcHPjvXLQq2i80No61oKF/165+OvEC2iUBmVTxl6ka87yNECwYf4nrspHKqP9HbVWndtNP0lGgrnz7bo0XCw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-jobs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-launch-environment": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-launch-environment/-/dsh-launch-environment-0.1.0-rc.6.tgz", + "integrity": "sha512-tTRJ1464PJUDe1Em1qq0mfdgGREzGGWo3JSqP6xeYDoX+MRXVP9/ChsZ5k6VBMjARAxw0HGBf7WR6VcupHbMZg==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-llm": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm/-/dsh-llm-0.1.0-rc.6.tgz", + "integrity": "sha512-kuFGC8bHlzGTwlRxQhXjf3CYWl8M4NzH+EYIkrW8rri4iMc9W53xrdvkil5No/DUlMm8g1u7GdeiWYFy0TMvtA==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-attachment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-llm-deepseek": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm-deepseek/-/dsh-llm-deepseek-0.1.0-rc.6.tgz", + "integrity": "sha512-Md9ik1e8tmrKcL2aRpif8qdndl9TObbfHodj6CeVlkrL9y+PEghVPZqOYtnuRcv4/e0nVSIJsUw2gFvaL+T40Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "eventsource-parser": "^3.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-anonymous-user-id": "^0.1.0-rc.6", + "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-llm-pi-ai": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm-pi-ai/-/dsh-llm-pi-ai-0.1.0-rc.6.tgz", + "integrity": "sha512-5RvzkpVCYLg9A3IGdm04px7XOaF/xikuMLe2toBY4A0qtJraXiZtUN1QBOL9i6u7DTOLG9oHP/USsbWRpyI+1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "@earendil-works/pi-ai": "^0.82.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-attachment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-credentials": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-launch-environment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-llm-retry": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm-retry/-/dsh-llm-retry-0.1.0-rc.6.tgz", + "integrity": "sha512-HdjBWcQ6spwA8B5dScRhndLQNiKoQTkq36O+kmJ80B9V4z08K5XrJwAInH9cCxJ91CqNzx/sxCdDds0oZDH1Pw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-output-retention": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-output-retention/-/dsh-output-retention-0.1.0-rc.6.tgz", + "integrity": "sha512-3TQJxGqjotWrvNKI+Da8nIDjzpKZEGpImeMBp7EklfO0CDbiXA5B0Ms39zBniWkWr1lvbm2iJiYERY5uDeP6og==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-persona": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-persona/-/dsh-persona-0.1.0-rc.6.tgz", + "integrity": "sha512-rY5AVVopkS9vJl+hGHGQP0nSwzxawubRvEKo4JZmGK7bKZlvevRvj8bd2wLNhTx54jppz+u0iEUq/fqG5k9Mvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-repeat-tool-reminder": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-repeat-tool-reminder/-/dsh-repeat-tool-reminder-0.1.0-rc.6.tgz", + "integrity": "sha512-iN9FjWXL8e/IrxTFmybTKDqtB6obV5jt6ao2I3EQWOhQ0GVfXBuz1SY6Yx7gILQXWsCUeGRu1ltLyywdubpJcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-sandbox": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox/-/dsh-sandbox-0.1.0-rc.6.tgz", + "integrity": "sha512-SLZjuivQQKHTx4H7xlsjraEGGMIEA8BYOwvlm/9uZBrBrlttMU+d8Ne6QYpDcOwIEubL8mvrWRg8j3rtY3lVyA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-sandbox-local": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox-local/-/dsh-sandbox-local-0.1.0-rc.6.tgz", + "integrity": "sha512-W0CehRbWqAaHAYFw24wvYLlGxxJr2OlQQlFH/p1QbeNKtHzZ+6pgjksyFotgHdzOycNnVoBjb92RbQPfJhJZ9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-sandbox-windows-acl": "^0.1.0-rc.6", + "@deepseek-ai/node-addon-landlock-run": "^0.1.1", + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-sandbox-policy": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox-policy/-/dsh-sandbox-policy-0.1.0-rc.6.tgz", + "integrity": "sha512-XEB7+pJZVPWmZXv27EX/4V/1noVLUF5ZHyy3EY5tC4XtEyayWKtvHL4y9OTLIYhlU4Ualv5ag8s5ueGGq3ID8Q==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-sandbox-windows-acl": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-sandbox-windows-acl/-/dsh-sandbox-windows-acl-0.1.0-rc.6.tgz", + "integrity": "sha512-/L1TUOQMsJe8B2v1pJpTaLwkvMQIwrjlUg4+6yx2flU7AyycjFzlKwmzQQzo19/evkiqO88mLgod3FiLhqxEvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "koffi": "^3.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-scope": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-scope/-/dsh-scope-0.1.0-rc.6.tgz", + "integrity": "sha512-UlDLV4syLoJinNg9imhXrSAHrdaTa5Ff8gg46rzjFJGPUOhAk3DZff0hryT5OhrBi0A5Tj92qVpg2pRVvxnUzQ==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-session": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session/-/dsh-session-0.1.0-rc.6.tgz", + "integrity": "sha512-8tu8I6VWC7050GAUXWhcEWQw4pakALQc8TlhKr52m7Y4+kIKeNt3FBgP86PaGPBtpK0p5zUPRQNkFpzZbBdxyw==", + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-session-checkpoint-policy": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-checkpoint-policy/-/dsh-session-checkpoint-policy-0.1.0-rc.6.tgz", + "integrity": "sha512-wdWNyS/95wI3QB9SHeeeQ9HDIWeFQKInI54Wqlw35H+bGGjGZE92nx9QiqiiosoxHedQFBdA0TKgON4EIoxCgg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-session-persistence": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-persistence/-/dsh-session-persistence-0.1.0-rc.6.tgz", + "integrity": "sha512-AbNBe+IYCbZqSHqOACVdj8QTynm2HZ0cThrEuI6nGMtlWLYLx6lzZ1rgO/56Av9mIScjyTBGJAIKhEYaMTBG9g==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-session-persistence-jsonl": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-persistence-jsonl/-/dsh-session-persistence-jsonl-0.1.0-rc.6.tgz", + "integrity": "sha512-US9Q4b5CZJPRRqa/M+WESL5VAOLjfYWjdzb3TQ/7zmpR4/+EQWCOFA9CtDSMh4t1oFOQBjz2+YJJcGu59MKHDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "koffi": "^3.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-session-projection": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-projection/-/dsh-session-projection-0.1.0-rc.6.tgz", + "integrity": "sha512-DYLALBPdEI1LZjJ4B6rdGdGY4gy+iR2+5Xh2xJoAGa/pTyN5Z55TNfvuMJrmeRBjAuDXNUQpmURbvos5rJ4veg==", + "license": "MIT", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-session-query": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-query/-/dsh-session-query-0.1.0-rc.6.tgz", + "integrity": "sha512-bGzRrZnWuXDUtAmwkRxT74Nuc4PLQJmJ4IeGgSSTIGJ9JQCNbvVjr4ZWDWg+D/PvbZwh9mpYJspDZdUP42eWAA==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-title": "^0.1.0-rc.6" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-session-persistence": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/dsh-session-query-sqlite": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-query-sqlite/-/dsh-session-query-sqlite-0.1.0-rc.6.tgz", + "integrity": "sha512-sbeOPAAJetQbvgyFB7zVANSlJPjNgEGxiixin/DI9TcXHcUqq3nrPgS+1iI+WJjD6/zhHdIticeEayuWXtORdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-query": "^0.1.0-rc.6" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-session-persistence": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/dsh-session-title": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-title/-/dsh-session-title-0.1.0-rc.6.tgz", + "integrity": "sha512-RHV1ujuomvQB63HPI/n25c2/2It5XQ7zNQKozSqYOPQw7AIr16PT9iFqC7sCQ4l2gAY5GI3FbI1cQgHzjHNOag==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-settings": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-settings/-/dsh-settings-0.1.0-rc.6.tgz", + "integrity": "sha512-5ZlH2FNRU0kkdcCoEJohvdb2fiNnxM+iZqN3icbR3WQbMm8RPRrXlZlps5YUaQKPRbNRHA2LkOXW998QRlhHcA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/schemastery": "^3.18.1" + } + }, + "node_modules/@deepseek-ai/dsh-settings-file": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-settings-file/-/dsh-settings-file-0.1.0-rc.6.tgz", + "integrity": "sha512-OgwvP/dTlrWd8MRhjJbeolMJHDdyjLtvd+gS3z2mvCGDmNkkf0RxcB/8o2uoqe97iuklTWmy6cCws6dlmnDL+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "chokidar": "^4.0.3", + "yaml": "^2.9.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-atomic-write": "^0.1.0-rc.6", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-shell": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-shell/-/dsh-shell-0.1.0-rc.6.tgz", + "integrity": "sha512-l/pcwbdpM+Zgp2AzH0kH0z7bPU7L3k4uG1t5Du1V5ilk70tyMSxz2upNfAbjkDHZZZO18LTs33aq+zRWHX1CUg==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-settings": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-shell-env": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-shell-env/-/dsh-shell-env-0.1.0-rc.6.tgz", + "integrity": "sha512-znxHFSqduw8U/AxKSO8SgSt703Bl+irRzTU0ukJT+ySlnyAwSAg8sZHLo5ZlEt5K8BdWhxz8Vc5jibOtJPB3ZQ==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6", + "@deepseek-ai/dsh-shell": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-skill": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-skill/-/dsh-skill-0.1.0-rc.6.tgz", + "integrity": "sha512-VyRlCOASIRuTflKwn4NvbdAXxSPnnLJ+RAQUI4GK5pOtPwI2DhJrbovqpyp5kNAkZMsqvIeHWu5PcCEJ+FpQ2w==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-skill-filesystem": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-skill-filesystem/-/dsh-skill-filesystem-0.1.0-rc.6.tgz", + "integrity": "sha512-NCgz8lCAdvd0oSGIF0EKg1OXfIC7x9r0an7mgC19F0DGzYZJe5ve6AwEYpSvKITE3V8W4NbUzLKyyZG3VH5QtQ==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "chokidar": "^5.0.0", + "yaml": "^2.4.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-fs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-home-paths": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-skill": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-skill-filesystem/node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@deepseek-ai/dsh-skill-filesystem/node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@deepseek-ai/dsh-spill": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-spill/-/dsh-spill-0.1.0-rc.6.tgz", + "integrity": "sha512-reV4utwd3E5LgiNrsaqh6i6zxHboVyrrL1ilALRukDbMxSyup1vuSRh+4CPxSUh9YcoToFJ6jz2Zmlsw755g1w==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-subagent": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-subagent/-/dsh-subagent-0.1.0-rc.6.tgz", + "integrity": "sha512-vROmBDAlaFAzzSlTBOlvg/7fO55zxhUztnLtB3lKmN5RevrNQBjTsbeIMDQ8ow5ZplxEOnLU+sikFoA5JaoH8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-agent-presets": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-jobs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection-cache": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", + "@deepseek-ai/dsh-user-approval": "^0.1.0-rc.6" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-agent-presets": { + "optional": true + }, + "@deepseek-ai/dsh-jobs": { + "optional": true + }, + "@deepseek-ai/dsh-sandbox": { + "optional": true + }, + "@deepseek-ai/dsh-sandbox-policy": { + "optional": true + }, + "@deepseek-ai/dsh-session-persistence": { + "optional": true + }, + "@deepseek-ai/dsh-session-projection": { + "optional": true + }, + "@deepseek-ai/dsh-session-projection-cache": { + "optional": true + }, + "@deepseek-ai/dsh-user-approval": { + "optional": true + } + } + }, + "node_modules/@deepseek-ai/dsh-subagent-in-process-driver": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-subagent-in-process-driver/-/dsh-subagent-in-process-driver-0.1.0-rc.6.tgz", + "integrity": "sha512-KK8Ep4PeSyrM3YQdcZhetXsiBd+bliKu6NWWKyTh60LobKtVNREfPBlqRfzc0mU0ZIKD4VEC7RfuJaqvz4UlPw==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subagent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-subagent-spawn-in-process": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-subagent-spawn-in-process/-/dsh-subagent-spawn-in-process-0.1.0-rc.6.tgz", + "integrity": "sha512-62mtUEr5megxVy6CwQCdZVq5MCSt+kMw74ns5m7PK0PlZTWcxQVBQHNdfkE9sX4Cageu7YTvciUJuH8amm6bsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subagent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subagent-in-process-driver": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-subprocess": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-subprocess/-/dsh-subprocess-0.1.0-rc.6.tgz", + "integrity": "sha512-nZaZRjSnE1he+GAd14vURAH7n3Fw5eGx3nzMbkB96Y6Qx+oQB/7PTuXxKVxlCh8TSfMJTf851ahwWb1eALjKlw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-subprocess-local": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-subprocess-local/-/dsh-subprocess-local-0.1.0-rc.6.tgz", + "integrity": "sha512-D2daTRaprE25ti1Ra69eURPwLULpnRyUHqAGtcMHddcjNIsZ4yR0DGinYOsgoRymzu3t02qLxyoio05atSViRw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-pty": "^1.1.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-system-prompt": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-system-prompt/-/dsh-system-prompt-0.1.0-rc.6.tgz", + "integrity": "sha512-E7g+XChh4q4/wX++v56z1pV4SA1Rtz42xkznLPPi9FlXrrzJxwHMOUzBZ9Rz3Y1kLhQ++HJG3ZatmNx3rjFilg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-terminal": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-terminal/-/dsh-terminal-0.1.0-rc.6.tgz", + "integrity": "sha512-riHLAQhXJJ2TT9XrDWGMgJf21RBqQgSZdEVvG4+xGEf9NZWcoszxdp+KW7rpwfrKuuD70p8RHCPGW4gCQPA+/A==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-terminal-bash": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-terminal-bash/-/dsh-terminal-bash-0.1.0-rc.6.tgz", + "integrity": "sha512-KRR/b/qqKO+sf6yc0xPC64LCZ+SQgTAnEMSO7gNXQ4BeAIzUbeKYfeVYiG6XZRbQ6al6o5sWIbZ0lUt3pHGdXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6", + "@deepseek-ai/dsh-terminal": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-timeout": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-timeout/-/dsh-timeout-0.1.0-rc.6.tgz", + "integrity": "sha512-CUean0fAnfsJVszFEip7PsU/S26W+JfDFfsza2dCtlw8n6xlkbHA9Gjxdk2aTwqDGCgXEPkRW7mYkdJ0n6FR7w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-token-meter": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-token-meter/-/dsh-token-meter-0.1.0-rc.6.tgz", + "integrity": "sha512-qU5FT4n1RJXP3Ss8NJ5TTvPo8rZ6cxcrCedp1t0sTAj93SGssAanq47voSmznPP52nwz/SLeunznAIT0jhbsZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-compaction": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-bash": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-bash/-/dsh-tool-bash-0.1.0-rc.6.tgz", + "integrity": "sha512-yzo/xPCObiFe+OU0e8E5nVOGpW6mp9vx/3q+MHQ0aqEbqrqHUfVMGPobfDAd/I1Ja/9Nag5NIvHpI2Hwx7lACg==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-jobs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6", + "@deepseek-ai/dsh-shell": "^0.1.0-rc.6", + "@deepseek-ai/dsh-shell-env": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", + "@deepseek-ai/dsh-user-approval": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-bash-persistent": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-bash-persistent/-/dsh-tool-bash-persistent-0.1.0-rc.6.tgz", + "integrity": "sha512-bsM1RHwkpNRAy0zufIDNUWxeiuGngc+R35RZ20gbWM878rHOZVuicy4MG8NKhzqGLqMD4NuDoNfjtDUkZHC48A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-terminal": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-fs": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-fs/-/dsh-tool-fs-0.1.0-rc.6.tgz", + "integrity": "sha512-bJfo8cCacX3WBygG8GbIlGz3jQ15MyIv4htGCjKpIIRHrXHSAelNtzceO2xQ21/9BkXrvg+i50mqySOcKQ0b3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "diff": "^9.0.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-attachment": "^0.1.0-rc.6", + "@deepseek-ai/dsh-fs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6", + "@deepseek-ai/dsh-user-approval": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-fs-search": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-fs-search/-/dsh-tool-fs-search-0.1.0-rc.6.tgz", + "integrity": "sha512-MUu9QF2Du8F6zdFP5rPEti9LKr/IyCYLM24XbaeuZZCrbq6Q1Rf9sng3v5bD9TlT+Wm+uXv3AqPX+8r8gcdqIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "@vscode/ripgrep": "^1.18.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-output-retention": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-spill": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subprocess": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-timeout": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-goal": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-goal/-/dsh-tool-goal-0.1.0-rc.6.tgz", + "integrity": "sha512-5rYb1wWJa2HfCxi1JNMkPJwN0upoblJEVlnV+omTx4ZDGekg2dyfftaQdFH9cwiW576is8bAUgPE9Rd0sZCAzw==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-goal": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-jobs": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-jobs/-/dsh-tool-jobs-0.1.0-rc.6.tgz", + "integrity": "sha512-mcftmyHx2bq8u5Qwo7znysa3vOKO86ccJvgEZqfDV7S+UhOGbPDue7psyx1/jD/5fbK+653JbxQ8ds9wsLkiPw==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-jobs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-output-retention": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-skill": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-skill/-/dsh-tool-skill-0.1.0-rc.6.tgz", + "integrity": "sha512-AebSfHSt6j0PXuN3yK9+X4aC6tHSnFdm2hZglII7aEHr/lL9UbynPb3NTumh6thXZ9Earvwhf9JdOxHMsjHqfw==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-skill": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-str-replace-editor": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-str-replace-editor/-/dsh-tool-str-replace-editor-0.1.0-rc.6.tgz", + "integrity": "sha512-Kv+zA9ZQCn85VLfox2WVvNI/iU6e3ml4gPLqXiKMC027/iwmGHTcRMKOlDNnwydFTJOucNs8wW4Ac3FafCn3gA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-fs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox": "^0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-subagent": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-subagent/-/dsh-tool-subagent-0.1.0-rc.6.tgz", + "integrity": "sha512-yK+ETLxDZhtwc19uqv5yAVzq3x/i2hjV9p67Or1a+s9Eqs+eu+ZJJk5hQ/2JeRiJet2otOC4zzOkYb/UVSBFyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-jobs": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subagent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-subagent-control": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-subagent-control/-/dsh-tool-subagent-control-0.1.0-rc.6.tgz", + "integrity": "sha512-zoVt+uV8yyg1dAuIMydtz/AfARZPkjFOXQLd90a3Bj8GdxpE3tdaR6ZIv3xD39Grxe2vRz4gOZEJ9qth3O+s9g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subagent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-subagent-report": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-subagent-report/-/dsh-tool-subagent-report-0.1.0-rc.6.tgz", + "integrity": "sha512-DqnJ6yJ0wO35jdcpCSuogbx/PC4xE8wrcVxXPvSw9I59p/8nROsfwB9UXuJoilp/wenREUoTi0X8+5ZOVSRLqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-subagent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tool-todo": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tool-todo/-/dsh-tool-todo-0.1.0-rc.6.tgz", + "integrity": "sha512-ll9xmgkrV6nsxjEZn5+YkNwQei7WrMMisnbr9mAKmqN5vpyOVHXisLvKdiASEmgpm4rsicnR8Y0wcBUfbeJTxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection": "^0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-tools": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tools/-/dsh-tools-0.1.0-rc.6.tgz", + "integrity": "sha512-Tu08EPK3JyK0iNjH4FGzu/1uADynNSS6SmwOLdfytUN0YNqwNuKFSt2OJUg19famNlTgy992DcHfDu0T+gLXFg==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-code-runtime": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6", + "@deepseek-ai/dsh-user-approval": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-typert-protocol": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-typert-protocol/-/dsh-typert-protocol-0.1.0-rc.6.tgz", + "integrity": "sha512-weWzN8r01YCkoDCAM7BsKw2YhRrD4zL8N2SAZu9hovYtXSq8xHXsP4Zh8RLYIlYcuotjyff/6hic+0TJPd14YA==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/dsh-user-approval": { + "version": "0.1.0-rc.6", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-user-approval/-/dsh-user-approval-0.1.0-rc.6.tgz", + "integrity": "sha512-9rnkSDGOpu2XUeGwbPeTzVUTFWTND1PMPM5L/ZQPptV5yyZlQiNxM2rCC6OdL+ZVerwxEqrRhZIQn/KVtQfKag==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-agent": "^0.1.0-rc.6", + "@deepseek-ai/dsh-brand": "^0.1.0-rc.6", + "@deepseek-ai/dsh-invariants": "^0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "^0.1.0-rc.6", + "@deepseek-ai/dsh-scope": "^0.1.0-rc.6", + "@deepseek-ai/dsh-session": "^0.1.0-rc.6", + "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6" + } + }, + "node_modules/@deepseek-ai/node-addon-landlock-run": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/node-addon-landlock-run/-/node-addon-landlock-run-0.1.1.tgz", + "integrity": "sha512-aHGhlQJEutfobKM/4K59SERbT7RmQdD2oMKzD8Bne/Ps7TeT8AweCN+dpdfuxQhMNbFcJMymrgPnID0WYQ30Tw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@deepseek-ai/node-addon-landlock-run-linux-arm64": "0.1.1", + "@deepseek-ai/node-addon-landlock-run-linux-x64": "0.1.1" + } + }, + "node_modules/@deepseek-ai/node-addon-landlock-run-linux-arm64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/node-addon-landlock-run-linux-arm64/-/node-addon-landlock-run-linux-arm64-0.1.1.tgz", + "integrity": "sha512-lYY2RbcPW4rGRM5hVJbrXlvLqyBxeJBjBqvt+QTHTU+GtfUVVjTODKa4e3CRwMQoCEpOjoARdQBHbN7HvE72WQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@deepseek-ai/node-addon-landlock-run-linux-x64": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/node-addon-landlock-run-linux-x64/-/node-addon-landlock-run-linux-x64-0.1.1.tgz", + "integrity": "sha512-OHAzPW2Coe/iYobAJAAA8CeVrBoKV4BnNHsgwvXwOfishxkUVSWSvdyxrZPiwYRXutpIGVrSo9zV3WOQy2euBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@deepseek-ai/schemastery": { + "version": "3.18.1", + "resolved": "https://registry.npmjs.org/@deepseek-ai/schemastery/-/schemastery-3.18.1.tgz", + "integrity": "sha512-Qn0FCSwCQnpnj6SB31I6i2sIKgKWnkbJM8O0EU91Gv2UsYVvtZTl6IA0sCwk2e2MZf5S8w5hpq9QkeVvK9qwxg==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/cosmokit": "^1.8.2", + "@standard-schema/spec": "^1.1.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.82.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.82.1.tgz", + "integrity": "sha512-3WFYRhEp3lQB3444EhPMBcM7zSaEUE3eJgHOR7s4081NLqbw/FsWilIKWXSua0Gv3sRr7m9xMidR3pPDE7jI/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "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/@koromix/koffi-darwin-arm64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.5.tgz", + "integrity": "sha512-IpqITl2fJi3QN9bTtNnygWPdK7ScSjw3xtGu8e6feYGvimCysu+spgI5KyeslY2jTnqxGS9xr8pLAbLhGJ8edA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.5.tgz", + "integrity": "sha512-4Tia4BS5EV/+vN9eIrdToanVe+U/2VqTZCBgOzoUbPKjgky51eqM+3J4qdRUvmYJohcJNPob5/hsxeItUZrl1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.5.tgz", + "integrity": "sha512-bP94uzseFO79NG3flpU3WxfyvltD+jzC/kN8FDZLi8J0VUZNW1Wmu8yq87KEzjX1qT9KyX4Y+elVbGqHXHTv2Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.5.tgz", + "integrity": "sha512-raFXXAPHzvCQWhaoMUF+Cc2ZWgg2UBU0RVoowHZhaw9nQYPC1pERcPRH+JA+SNIN6g4d2GFW6uPFc+QbUhsagA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.5.tgz", + "integrity": "sha512-h6RyBZmPMBIDWTABkJIhzDdYwSnYAJvTacHpEjbT55Arkmw1H15Rl7CFtXuEuBrqh+uivoCrRgA6vszl9CsJ9g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.5.tgz", + "integrity": "sha512-u0vCmKPu4yQDhl/ri1J6U3vDnvYtYjoZaIWb+oMbRXhVZeiqdE53MGPb+q2A7Dj2n9IbYloAfEICQbL6l0pmiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.5.tgz", + "integrity": "sha512-Xa5JbumWglwPVZgrJcLhqyC1wCWlfm7+C00p3FuOTNGp0qoYuf2/tOoAh0/q7+taVm30cMopu/6lRHwdmF6I/w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.5.tgz", + "integrity": "sha512-F3i2CeTcqVBUQiSRUBSEzX1VgtXmLLiZb/ouZtXHkWpTLhJNd9TH7s3CizTofci0VRqlKdexGUYhK5vzPDAAHA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.5.tgz", + "integrity": "sha512-2TgQuzy+4PfDg+rw3kOmN6lywEWdzKT3eaLPbOp0b/9DaN7CLBJ/QIR5GhGsMNpeI30zU0YzFeYFxoVoJ/LqFw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.5.tgz", + "integrity": "sha512-2yaIg/1V0m4CiAUMzG4CIlWmq1WJ+QBMlFfaGr9su+OH5fuIqC7V3BbMiB03IwIl1VofIZO5JA4Db4lID8tpbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.5.tgz", + "integrity": "sha512-8/OXd+u9omMooykhvdJPEP7u6FFzzrrFo9gOmSHc9/DPt3XkVYOtSsE97PDk6zYaAzwIYHSKjHvIXsfFwFc7sg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.5.tgz", + "integrity": "sha512-SpeqldKkuDk2aTj5PVWumy7eq6Tr2GtBPAOI1NiDHhg8xe433KraxrA9V9UjEd+1+kSGJQGR04nQByN3MPA9PQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.5.tgz", + "integrity": "sha512-uej3YAEKAhlfVPoIo5sOwtxhTLRVJ01LgtWrKGpnnAQU3C+Ilmaxdh+Oc2xc1G3NK30N5eCVJpyO9r3pjKC6Vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.5.tgz", + "integrity": "sha512-d42jv2f4PwtJGNJS19Xfn/BRtGsBNNVkw0O0K5tkIGI+yNq4MnPTSUsaGbDIkCLNsCgk/LFqAaE8BExyCdrujA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.5.tgz", + "integrity": "sha512-Pyo1WEHEP6Ek2NEn2pquwJzSPLOdY4vymoPSz0an1DgvFWSkOyBYYkGVoxL1ajfj5I1pPdXysYqixtVTzAtOfQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@mistralai/mistralai": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@smithy/core": { + "version": "3.33.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.0.tgz", + "integrity": "sha512-uKbkxgqLyepQDZoq8aRSdUqD1ID//rOqG96ixBhp++O7vBtmwYM6fwldGhr9HJP0iYrdc7GP/AlgzPWEZIrNRg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz", + "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz", + "integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz", + "integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz", + "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "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": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.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==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vscode/ripgrep": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.18.0.tgz", + "integrity": "sha512-ns5lWe44tSfbTMbVUsyB+I1819PVSw4AdpgK0RNkzfWfwy6+3IUNSxwSrfTno1/oWaS/hERNz+XLWVyga2aJBQ==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "@vscode/ripgrep-darwin-arm64": "1.18.0", + "@vscode/ripgrep-darwin-x64": "1.18.0", + "@vscode/ripgrep-linux-arm": "1.18.0", + "@vscode/ripgrep-linux-arm64": "1.18.0", + "@vscode/ripgrep-linux-ia32": "1.18.0", + "@vscode/ripgrep-linux-ppc64": "1.18.0", + "@vscode/ripgrep-linux-riscv64": "1.18.0", + "@vscode/ripgrep-linux-s390x": "1.18.0", + "@vscode/ripgrep-linux-x64": "1.18.0", + "@vscode/ripgrep-win32-arm64": "1.18.0", + "@vscode/ripgrep-win32-ia32": "1.18.0", + "@vscode/ripgrep-win32-x64": "1.18.0" + } + }, + "node_modules/@vscode/ripgrep-darwin-arm64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-darwin-arm64/-/ripgrep-darwin-arm64-1.18.0.tgz", + "integrity": "sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/ripgrep-darwin-x64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-darwin-x64/-/ripgrep-darwin-x64-1.18.0.tgz", + "integrity": "sha512-25b4gWbL138dGuQU244ebCKKc0q05ULBMoFSz9oAEUHNeqK/lOJViDS7DRvbDazzAzSEdan391Znks/R5mkaTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@vscode/ripgrep-linux-arm": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-arm/-/ripgrep-linux-arm-1.18.0.tgz", + "integrity": "sha512-GDAvufNDHu8zqLEmXstalQF0Wh6wQvdsBi/Vg3Yi3CK4a8XoFXqqXVEHEZ9xQz3t0NfoSEc9JbvK9DDS6FxyxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-arm64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-arm64/-/ripgrep-linux-arm64-1.18.0.tgz", + "integrity": "sha512-lQ/5zTG++U0E3IhVgS4EPTTn/U4okncaRMM5GOFfOYZywS4nuD31GhkHbNYlDk5CuDC68+hYJ0/eQeyCKJDA+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-ia32": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-ia32/-/ripgrep-linux-ia32-1.18.0.tgz", + "integrity": "sha512-YWLkSUtFd4Jh5EepIhA9RJSfv3uMAVMo+2rBIGHPBnvgLrZciIs2cDKei1/p6Wc/aCzUoHyMAg2R6tw4ZCBKGg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-ppc64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-ppc64/-/ripgrep-linux-ppc64-1.18.0.tgz", + "integrity": "sha512-quXVY8fwQ8O/lvU1yrSqSl3jlUzysRSb+AfUfCL/tRtphxsKlFvPAejryZ6vg4Bgvn8XL74xb4qMCDmWgYrT5w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-riscv64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-riscv64/-/ripgrep-linux-riscv64-1.18.0.tgz", + "integrity": "sha512-f5kBQBrWfQt8Q7OhSORuNDei5dkYagBj3y4jImSUXGMy8B/Ke7SltSRcUtjPv166FAFfHCAmWuZp3+cWnX2/Vw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-s390x": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-s390x/-/ripgrep-linux-s390x-1.18.0.tgz", + "integrity": "sha512-rTOcJFGGcl2c07RUOWUo4U1ndnemKhY6A9hnMB18uk7jSgJc0d/QLBGWMWpumdtoJtpizn/wIv5mXIisJukusQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-linux-x64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-linux-x64/-/ripgrep-linux-x64-1.18.0.tgz", + "integrity": "sha512-mQ3bVrUpnD2vs7QT0vX90Lt0cnUq467uFtEktIdsJJmW296RoSULRGqWgzG1AKxyBpNDD6l4ZO4qKf6SgyC23Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@vscode/ripgrep-win32-arm64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-arm64/-/ripgrep-win32-arm64-1.18.0.tgz", + "integrity": "sha512-vfTIjq1OHnzUjxZcHVQAMbnggp8dpGf+0QKFOZHwWPqFwXxQC8eCWM+5NUdoJ6yrElCeMzoUTXoK/LdZaniB+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/ripgrep-win32-ia32": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-ia32/-/ripgrep-win32-ia32-1.18.0.tgz", + "integrity": "sha512-//rfAE+BOw5AC2EMmepmiE36jUuevtQYNQqqlw1s3m9FlRxjxEut97RkRPHAu9BG4mSojatZx+kXZXNdyI9caQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@vscode/ripgrep-win32-x64": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/@vscode/ripgrep-win32-x64/-/ripgrep-win32-x64-1.18.0.tgz", + "integrity": "sha512-KNPvtElldqILHdnAetujPaowkNbpqJy3ssIGGN6F6Kve9Qi+nNLI2DN01O83JjCEVQbCzl8Ov3QZ9Eov3BR8Dg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "devOptional": true, + "license": "Python-2.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/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/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/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "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/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "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/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.2.tgz", + "integrity": "sha512-XpwZALwwl/BaKTAyC6+c5T8y6kCg2jk+XGqOVrKIQmW49pNypYLMRjCUXqa28tQgJlhS2RlzP7sc+Rx7W6qsfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "devOptional": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/koffi": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.5.tgz", + "integrity": "sha512-XVwwrxg0Ca6IEUQF4YtGIU4XN0LSselFYpYvgfhh8wafCunhEEx5hPr7LZhp5QyeFA/LcRsKHTqncCdjWjWAlg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.1.5", + "@koromix/koffi-darwin-x64": "3.1.5", + "@koromix/koffi-freebsd-arm64": "3.1.5", + "@koromix/koffi-freebsd-ia32": "3.1.5", + "@koromix/koffi-freebsd-x64": "3.1.5", + "@koromix/koffi-linux-arm64": "3.1.5", + "@koromix/koffi-linux-ia32": "3.1.5", + "@koromix/koffi-linux-loong64": "3.1.5", + "@koromix/koffi-linux-riscv64": "3.1.5", + "@koromix/koffi-linux-x64": "3.1.5", + "@koromix/koffi-openbsd-ia32": "3.1.5", + "@koromix/koffi-openbsd-x64": "3.1.5", + "@koromix/koffi-win32-arm64": "3.1.5", + "@koromix/koffi-win32-ia32": "3.1.5", + "@koromix/koffi-win32-x64": "3.1.5" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "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/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^7.1.0" + } + }, + "node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, + "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/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "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.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/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": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.20.3", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.3.tgz", + "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.25.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "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", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/packages/dsh-acp/package.json b/packages/dsh-acp/package.json new file mode 100644 index 0000000000..a8e3ecc576 --- /dev/null +++ b/packages/dsh-acp/package.json @@ -0,0 +1,116 @@ +{ + "name": "@bitfun/dsh-acp", + "version": "0.0.1", + "private": true, + "description": "IDE-oriented Agent Client Protocol server for DeepSeek Harness: publishes tool calls, reasoning, and plans that the automation-only @deepseek-ai/dsh-acp deliberately withholds", + "license": "MIT", + "type": "module", + "main": "lib/app.js", + "types": "lib/types/app.d.ts", + "bin": { + "dsh-acp-ide": "lib/bin.js" + }, + "exports": { + ".": { + "types": "./lib/types/app.d.ts", + "default": "./lib/app.js" + }, + "./bridge": { + "types": "./lib/types/bridge.d.ts", + "default": "./lib/bridge.js" + }, + "./bin": { + "types": "./lib/types/bin.d.ts", + "default": "./lib/bin.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib/**/*.js", + "lib/types/**/*.d.ts", + "cordis.yml", + "presets/**/*.yml" + ], + "engines": { + "node": "^22.19.0 || >=24.0.0" + }, + "scripts": { + "link:dsh": "node scripts/link-local-dsh.mjs", + "build": "tsc -p tsconfig.build.json", + "start": "node --import tsx src/bin.ts --config ./cordis.yml", + "test": "vitest run" + }, + "dependencies": { + "@agentclientprotocol/sdk": "0.25.1", + "@deepseek-ai/dsh-agent-spine-demo": "0.1.0-rc.6" + }, + "comment:dependencies": [ + "Every @deepseek-ai/* below is pinned to the 0.1.0-rc.6 train, the set npm dist-tag `next` points at.", + "(`latest` still points at 0.0.1-rc.1 for half of them, which is why a mixed install disagrees with itself.)", + "devDependencies are compile-time only: at runtime the profile resolves them out of the user own dsh", + "installation, which symlinks its whole closure into $DSH_HOME/profiles/node_modules on every launch.", + "This package is deliberately NOT a pnpm workspace member and carries its own package-lock.json, so that", + "installing the harness is the desktop bundle build cost alone rather than every contributor install." + ], + "devDependencies": { + "@deepseek-ai/cordis": "4.0.1", + "@deepseek-ai/cordis-plugin-loader": "1.0.2", + "@deepseek-ai/dsh-agent": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-default-model": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-instructions": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-loop": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-loop-testkit": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-presets": "0.1.0-rc.6", + "@deepseek-ai/dsh-agent-tool-presentation": "0.1.0-rc.6", + "@deepseek-ai/dsh-app-boot": "0.1.0-rc.6", + "@deepseek-ai/dsh-bash-sandbox": "0.1.0-rc.6", + "@deepseek-ai/dsh-code-runtime": "0.1.0-rc.6", + "@deepseek-ai/dsh-code-runtime-worker-thread": "0.1.0-rc.6", + "@deepseek-ai/dsh-compaction-basic": "0.1.0-rc.6", + "@deepseek-ai/dsh-credentials-local": "0.1.0-rc.6", + "@deepseek-ai/dsh-fs-observation-policy": "0.1.0-rc.6", + "@deepseek-ai/dsh-fs-sandbox": "0.1.0-rc.6", + "@deepseek-ai/dsh-goal": "0.1.0-rc.6", + "@deepseek-ai/dsh-goal-round-driver": "0.1.0-rc.6", + "@deepseek-ai/dsh-llm": "0.1.0-rc.6", + "@deepseek-ai/dsh-llm-deepseek": "0.1.0-rc.6", + "@deepseek-ai/dsh-llm-pi-ai": "0.1.0-rc.6", + "@deepseek-ai/dsh-persona": "0.1.0-rc.6", + "@deepseek-ai/dsh-repeat-tool-reminder": "0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-local": "0.1.0-rc.6", + "@deepseek-ai/dsh-sandbox-policy": "0.1.0-rc.6", + "@deepseek-ai/dsh-session": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-checkpoint-policy": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-persistence-jsonl": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-projection": "0.1.0-rc.6", + "@deepseek-ai/dsh-session-query-sqlite": "0.1.0-rc.6", + "@deepseek-ai/dsh-settings-file": "0.1.0-rc.6", + "@deepseek-ai/dsh-shell-env": "0.1.0-rc.6", + "@deepseek-ai/dsh-skill": "0.1.0-rc.6", + "@deepseek-ai/dsh-skill-filesystem": "0.1.0-rc.6", + "@deepseek-ai/dsh-subagent": "0.1.0-rc.6", + "@deepseek-ai/dsh-subagent-spawn-in-process": "0.1.0-rc.6", + "@deepseek-ai/dsh-subprocess-local": "0.1.0-rc.6", + "@deepseek-ai/dsh-terminal": "0.1.0-rc.6", + "@deepseek-ai/dsh-terminal-bash": "0.1.0-rc.6", + "@deepseek-ai/dsh-token-meter": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-bash": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-bash-persistent": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-fs": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-fs-search": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-goal": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-jobs": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-skill": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-str-replace-editor": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-subagent": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-subagent-control": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-subagent-report": "0.1.0-rc.6", + "@deepseek-ai/dsh-tool-todo": "0.1.0-rc.6", + "@deepseek-ai/dsh-tools": "0.1.0-rc.6", + "@deepseek-ai/dsh-user-approval": "0.1.0-rc.6", + "@deepseek-ai/schemastery": "3.18.1", + "tsx": "4.20.3", + "typescript": "5.9.2", + "vitest": "3.2.4" + } +} diff --git a/packages/dsh-acp/presets/code/agent.cordis.yml b/packages/dsh-acp/presets/code/agent.cordis.yml new file mode 100644 index 0000000000..85175b7ce1 --- /dev/null +++ b/packages/dsh-acp/presets/code/agent.cordis.yml @@ -0,0 +1,95 @@ +# The `code` agent preset: the standard coding agent, presented as Code Mode. +# +# Adapted from deepseek-harness/apps/cli/config/agent-presets/code (MIT, +# Copyright (c) DeepSeek — see ../../NOTICE.md). +# +# Everything in `standard` is here unchanged. What is added is the +# `tool-presentation` row: instead of one tool call per action, the model writes +# a TypeScript program against a generated SDK and `run_code` executes it, so a +# sequence that would be five round trips becomes one. +# +# The tool registry itself stays on the host plane — the agent loop's scheduler +# is its consumer — so what this preset owns is the PRESENTATION of that +# registry for agents joined to it. A `standard` session runs beside a `code` +# one in the same process, each seeing its own catalog. +# +# Note for IDE clients: in this mode the client sees `run_code` calls rather +# than one card per file read or shell command. That is the trade this preset +# makes, and it is why it is not the default. + +# ── identity ──────────────────────────────────────────────────────────────── + +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: |- + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + +- id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +# ── filesystem ────────────────────────────────────────────────────────────── + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: true + +# ── background jobs ───────────────────────────────────────────────────────── + +- id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + +# ── skills and goals ──────────────────────────────────────────────────────── + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── delegation ────────────────────────────────────────────────────────────── + +- id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + +- id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + maxDepth: 1 + +# ── planning ──────────────────────────────────────────────────────────────── + +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true + +# ── presentation ──────────────────────────────────────────────────────────── + +# Code Mode for agents on this preset. The row waits for the host's +# `codeRuntime` rather than assuming it: a deployment that composes no +# TypeScript runtime fails this preset at mount, naming this id, instead of at +# the first request. +- id: tool-presentation + name: '@deepseek-ai/dsh-agent-tool-presentation' + config: + mode: code diff --git a/packages/dsh-acp/presets/code/preset.yml b/packages/dsh-acp/presets/code/preset.yml new file mode 100644 index 0000000000..fc0836f479 --- /dev/null +++ b/packages/dsh-acp/presets/code/preset.yml @@ -0,0 +1,3 @@ +name: PTC 模式 +description: 具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。 +order: 2 diff --git a/packages/dsh-acp/presets/minimal/agent.cordis.yml b/packages/dsh-acp/presets/minimal/agent.cordis.yml new file mode 100644 index 0000000000..2967d1e3f5 --- /dev/null +++ b/packages/dsh-acp/presets/minimal/agent.cordis.yml @@ -0,0 +1,58 @@ +# The `minimal` agent preset: a fixed-prompt, two-tool coding-agent composition. +# +# Adapted from deepseek-harness/apps/cli/config/agent-presets/minimal (MIT, +# Copyright (c) DeepSeek — see ../../NOTICE.md). One deliberate change: the +# upstream preset shadows the host filesystem with a bare `dsh-fs-local`, which +# would put the editor outside the sandbox this deployment answers permission +# requests from. Here the editor resolves the HOST's sandboxed `fs`, so a write +# beyond the workspace still reaches the ACP client as a permission request. +# +# The persona is the complete system prompt, so global identity, tool guidance, +# and later assembly listeners cannot add prompt text. Runtime context snapshots +# are suppressed, and the model composes only persistent `bash` and +# `str_replace_editor`. Context compaction still runs — it is host-plane here. + +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: You are a helpful software engineer assistant. + complete: true + includeRuntimeContext: false + +# The PTY registry is an agent-owned service, so it lives in an entry-local +# realm. The backend still consumes the host sandbox policy and subprocess +# implementation, while the tool registers into the host tool catalog. +- id: persistent-shell + name: cordis:group + group: true + isolate: + terminals: true + config: + - id: pty + name: '@deepseek-ai/dsh-terminal' + + - id: terminal-bash + name: '@deepseek-ai/dsh-terminal-bash' + config: + timeoutMs: 300000 + + - id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 + description: |- + Run commands in a bash shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * You do have access to a mirror of common linux and python packages via apt and pip. + * State is persistent across command calls and discussions with the user. + * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + +# Registers into the host `tools` registry and provides nothing, so it needs no +# realm. It requires absolute paths and reads the host's sandboxed `fs`. +- id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 diff --git a/packages/dsh-acp/presets/minimal/preset.yml b/packages/dsh-acp/presets/minimal/preset.yml new file mode 100644 index 0000000000..026cee1181 --- /dev/null +++ b/packages/dsh-acp/presets/minimal/preset.yml @@ -0,0 +1,3 @@ +name: 极简模式 +description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent,系统提示固定且不注入运行时上下文。 +order: 3 diff --git a/packages/dsh-acp/presets/standard/agent.cordis.yml b/packages/dsh-acp/presets/standard/agent.cordis.yml new file mode 100644 index 0000000000..74f90259db --- /dev/null +++ b/packages/dsh-acp/presets/standard/agent.cordis.yml @@ -0,0 +1,97 @@ +# The `standard` agent preset: the full IDE coding agent. +# +# Adapted from deepseek-harness/apps/cli/config/agent-presets/standard (MIT, +# Copyright (c) DeepSeek — see ../../NOTICE.md). The CLI's rows for surfaces an +# IDE launch does not compose are dropped: pwsh, plan mode, the ask-user and web +# tools, workflows/ralph, and the codex/claude-code subagent products. +# +# This file is an AGENT-PLANE composition. `dsh-agent-presets` mounts it ONCE +# per process under a standing scope, and every session naming this preset joins +# it by scope parentage. The host composition (../../cordis.yml) keeps what a +# preset must not own: the registries themselves, the model route, the sandbox +# and approval stack, persistence, and the ACP transport. +# +# A row that PROVIDES a service must sit inside a group carrying an `isolate` +# realm, or it publishes into the process-global root realm where a second +# preset registering the same name collides. Every row below only registers into +# host registries (tools, prompt sections, skills), so none needs a realm. + +# ── identity ──────────────────────────────────────────────────────────────── + +# `{{model}}` and `{{cwd}}` resolve from the agent's own route and the cwd the +# ACP client supplied at session/new. +- id: persona + name: '@deepseek-ai/dsh-persona' + config: + text: |- + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + +- id: agent-instructions + name: '@deepseek-ai/dsh-agent-instructions' + config: + maxBytes: 65536 + +# ── shell ─────────────────────────────────────────────────────────────────── + +# The model-facing tool only. Its executor (`bash-sandbox`) and the environment +# registry (`shell-env`) are host-plane. +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +# ── filesystem ────────────────────────────────────────────────────────────── + +# Both register into the host `tools` registry and provide nothing. The `fs` +# service and its observation policy stay in the host composition. +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + config: + sampleOverCapGlobResults: true + +# ── background jobs ───────────────────────────────────────────────────────── + +# Only the model-facing controls; the task registry is host-plane because +# `tool-bash` above resolves it with `ctx.get` and would not see a realm here. +- id: tool-jobs + name: '@deepseek-ai/dsh-tool-jobs' + +# ── skills and goals ──────────────────────────────────────────────────────── + +# The skill registry, its filesystem provider, and the goal service all live in +# the host composition; these rows are the model-facing halves. +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +- id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + +# ── delegation ────────────────────────────────────────────────────────────── + +# The `subagents` registry, the spawn backend, and `tool-subagent-report` are +# host-plane singletons; this preset contributes the tools that resolve them. +- id: tool-subagent-control + name: '@deepseek-ai/dsh-tool-subagent-control' + +- id: tool-subagent-list-agents + name: '@deepseek-ai/dsh-tool-subagent-control/list-agents' + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + backgroundMode: continuable + maxDepth: 1 + +# ── planning ──────────────────────────────────────────────────────────────── + +# `todo_write` replaces the logged whole list for later model requests; the ACP +# bridge republishes each write as a plan update. +- id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + config: + allowParallelInProgress: true diff --git a/packages/dsh-acp/presets/standard/preset.yml b/packages/dsh-acp/presets/standard/preset.yml new file mode 100644 index 0000000000..2f346ac707 --- /dev/null +++ b/packages/dsh-acp/presets/standard/preset.yml @@ -0,0 +1,3 @@ +name: 标准模式 +description: 功能完整的编码 Agent:文件读写与检索、沙箱 Shell、后台任务、Skills、目标、子代理与 todo 计划。 +order: 1 diff --git a/packages/dsh-acp/scripts/build-profile.mjs b/packages/dsh-acp/scripts/build-profile.mjs new file mode 100644 index 0000000000..161b5137bc --- /dev/null +++ b/packages/dsh-acp/scripts/build-profile.mjs @@ -0,0 +1,208 @@ +#!/usr/bin/env node +/** + * Package the built bridge as a DeepSeek Harness PROFILE, so an IDE launch is + * `dsh --profile ` against the harness the user already installed. + * + * A profile is just a directory under `$DSH_HOME/profiles/`: a `package.json` + * naming its bundle layers and a `cordis.patch.yml` holding entries. This + * script writes one whose bundle list is EMPTY, which makes our composition the + * whole tree — no in-box bundle contributes rows, so a mode like `minimal` + * stays exactly as narrow as its preset says. The launcher rewrites a profile's + * `cordis.yml` to an empty root on every boot, which is why the composition + * ships as the patch layer instead. + * + * Nothing here installs a second copy of the harness. Every `@deepseek-ai/dsh-*` + * row resolves from the user's own dsh through `$DSH_HOME/profiles/node_modules`, + * the flat fallback the launcher maintains (one symlink per package in the + * installation's dependency closure). Only the two packages OUTSIDE that + * closure are vendored here. + * + * Usage: `node scripts/build-profile.mjs [--from-local] [--out ]` + * `--from-local` copies the vendored packages out of this package's own + * node_modules (a linked harness checkout) instead of fetching the pinned + * versions from the registry; use it offline, and expect a checkout's version + * rather than the pinned one. + */ + +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' + +const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +/** The profile directory BitFun materializes this build into. */ +export const PROFILE_NAME = 'bitfun-acp' + +/** + * The oldest `@deepseek-ai/dsh` this build is known to boot against. The bridge + * compiles against harness APIs; an older installation resolves the closure to + * packages this code predates, and the failure would otherwise surface as a + * module or service error with no hint about the cause. + */ +const MIN_DSH_VERSION = '0.1.0-rc.6' + +/** + * The only two runtime packages the harness installation does NOT already + * expose: the ACP wire SDK (not a harness dependency at all) and the agent + * spine, which ships as an examples package outside the `dsh` closure. Pinned, + * because they must agree with the harness train named above. + */ +const VENDORED = { + '@agentclientprotocol/sdk': '0.25.1', + '@deepseek-ai/dsh-agent-spine-demo': '0.1.0-rc.6', +} + +/** Basename of the build stamp BitFun compares before re-materializing. */ +const STAMP_FILENAME = '.bitfun-bridge.json' + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { out: { type: 'string' }, 'from-local': { type: 'boolean' } }, + strict: true, +}) + +const OUT = resolve(PACKAGE_ROOT, values.out ?? 'dist-profile') + +/** + * Turn the leaf `cordis.yml` into a profile patch layer. + * + * The transform is textual on purpose: every comment in that file explains why + * a row is where it is, and re-serializing through a YAML parser would drop all + * of it. Indenting the whole document under one `- insert:` is exactly the + * shape the in-box bundles use. + * @param source - the contents of cordis.yml. + * @returns the patch document. + */ +function toPatchLayer(source) { + const body = source + // The app row is loaded from source in development and from the emitted + // tree here; the Loader resolves both against the profile directory. + .replace(/^(\s*name:\s*)'\.\/src\/app\.ts'/m, "$1'./lib/app.js'") + .split('\n') + // Blank lines stay blank rather than becoming trailing whitespace. + .map(line => line === '' ? '' : ` ${line}`) + .join('\n') + return `# GENERATED by scripts/build-profile.mjs from cordis.yml — do not edit.\n` + + `#\n` + + `# The profile's own patch layer, applied over an empty root: with no bundles\n` + + `# in package.json, these rows ARE the tree.\n` + + `- insert:\n${body}` +} + +/** + * Copy a directory into the output, skipping what never ships. + * @param from - absolute source directory. + * @param to - absolute destination directory. + */ +function copyTree(from, to) { + cpSync(from, to, { + recursive: true, + dereference: true, + filter: source => { + const base = source.slice(source.lastIndexOf('/') + 1) + return base !== 'node_modules' && base !== '.git' && !base.endsWith('.map') + }, + }) +} + +/** + * Place one vendored package under the profile's own node_modules. + * + * Its transitive dependencies are NOT vendored: Node's parent walk continues + * past the profile into `$DSH_HOME/profiles/node_modules`, where the harness + * installation's closure already provides them. + * @param name - the package name. + * @param version - the pinned version, used when fetching from the registry. + */ +function vendor(name, version) { + const destination = join(OUT, 'node_modules', name) + mkdirSync(dirname(destination), { recursive: true }) + if (values['from-local'] === true) { + copyTree(join(PACKAGE_ROOT, 'node_modules', name), destination) + return + } + const staging = mkdtempSync(join(tmpdir(), 'dsh-acp-vendor-')) + try { + const packed = execFileSync( + 'npm', + ['pack', `${name}@${version}`, '--pack-destination', staging, '--silent'], + { encoding: 'utf8' }, + ).trim().split('\n').at(-1) + execFileSync('tar', ['-xzf', join(staging, packed), '-C', staging]) + copyTree(join(staging, 'package'), destination) + } finally { + rmSync(staging, { recursive: true, force: true }) + } +} + +/** + * Hash every file in the output so BitFun can tell one build from another. + * + * A version string is not enough: during development the bridge's version + * stands still while its code changes, and a stale profile on disk would look + * current. Paths are sorted so the digest does not depend on directory order. + * @param root - the output directory, already fully written except the stamp. + * @returns a hex digest over every path and its bytes. + */ +function hashTree(root) { + const files = [] + const walk = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) { + const path = join(dir, entry.name) + if (entry.isDirectory()) walk(path) + else files.push(path) + } + } + walk(root) + const digest = createHash('sha256') + for (const path of files.sort()) { + digest.update(relative(root, path)) + digest.update('\0') + digest.update(readFileSync(path)) + } + return digest.digest('hex') +} + +const manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')) +try { + statSync(join(PACKAGE_ROOT, 'lib', 'app.js')) +} catch { + throw new Error('lib/app.js is missing; run `npm run build` (tsc -p tsconfig.build.json) first') +} + +rmSync(OUT, { recursive: true, force: true }) +mkdirSync(OUT, { recursive: true }) + +// A hand-written profile manifest is enough: the launcher only reads +// `dsh.profile.bundles` from it, and tolerates the section being absent. An +// empty list is deliberate — see the module comment. +writeFileSync(join(OUT, 'package.json'), `${JSON.stringify({ + name: `dsh-profile-${PROFILE_NAME}`, + version: manifest.version, + private: true, + type: 'module', + dependencies: {}, + dsh: { profile: { bundles: [] } }, +}, undefined, 2)}\n`) + +writeFileSync( + join(OUT, 'cordis.patch.yml'), + toPatchLayer(readFileSync(join(PACKAGE_ROOT, 'cordis.yml'), 'utf8')), +) + +copyTree(join(PACKAGE_ROOT, 'lib'), join(OUT, 'lib')) +copyTree(join(PACKAGE_ROOT, 'presets'), join(OUT, 'presets')) +for (const [name, version] of Object.entries(VENDORED)) vendor(name, version) + +writeFileSync(join(OUT, STAMP_FILENAME), `${JSON.stringify({ + bridge: manifest.version, + content: hashTree(OUT), + minDshVersion: MIN_DSH_VERSION, + profile: PROFILE_NAME, +}, undefined, 2)}\n`) + +process.stdout.write(`build-profile: wrote ${relative(PACKAGE_ROOT, OUT)} (profile ${PROFILE_NAME})\n`) diff --git a/packages/dsh-acp/scripts/link-local-dsh.mjs b/packages/dsh-acp/scripts/link-local-dsh.mjs new file mode 100644 index 0000000000..ee0967ac42 --- /dev/null +++ b/packages/dsh-acp/scripts/link-local-dsh.mjs @@ -0,0 +1,233 @@ +#!/usr/bin/env node +/** + * Populate this package's node_modules by symlinking a local deepseek-harness + * checkout. + * + * This is the OPTIONAL developer path. The ordinary build installs the pinned + * `0.1.0-rc.6` train from this package's own `package-lock.json`; link against + * a checkout when you are changing the harness and the bridge together, and + * `scripts/prepare-dsh-profile.mjs` will then leave the linked tree alone. + * + * Usage: `node scripts/link-local-dsh.mjs [--repo ]` + * or set DSH_REPO. Falls back to sibling checkouts of this repository. + */ + +import { mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, statSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' + +const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +/** Harness packages that must resolve from this package's own node_modules. */ +const HARNESS_PACKAGES = [ + // Imported by src/. + '@deepseek-ai/cordis', + '@deepseek-ai/schemastery', + '@deepseek-ai/dsh-agent', + '@deepseek-ai/dsh-agent-instructions', + '@deepseek-ai/dsh-agent-presets', + '@deepseek-ai/dsh-agent-spine-demo', + '@deepseek-ai/dsh-app-boot', + '@deepseek-ai/dsh-llm', + '@deepseek-ai/dsh-session', + '@deepseek-ai/dsh-session-checkpoint-policy', + '@deepseek-ai/dsh-session-persistence-jsonl', + '@deepseek-ai/dsh-session-query-sqlite', + '@deepseek-ai/dsh-tools', + '@deepseek-ai/dsh-user-approval', + // Named by cordis.yml — the HOST plane. The Loader resolves these against the + // config directory. + '@deepseek-ai/dsh-agent-default-model', + '@deepseek-ai/dsh-bash-sandbox', + '@deepseek-ai/dsh-code-runtime', + '@deepseek-ai/dsh-code-runtime-worker-thread', + '@deepseek-ai/dsh-compaction-basic', + '@deepseek-ai/dsh-credentials-local', + '@deepseek-ai/dsh-fs-observation-policy', + '@deepseek-ai/dsh-fs-sandbox', + '@deepseek-ai/dsh-goal', + '@deepseek-ai/dsh-goal-round-driver', + '@deepseek-ai/dsh-llm-deepseek', + '@deepseek-ai/dsh-llm-pi-ai', + '@deepseek-ai/dsh-settings-file', + '@deepseek-ai/dsh-repeat-tool-reminder', + '@deepseek-ai/dsh-sandbox-local', + '@deepseek-ai/dsh-sandbox-policy', + '@deepseek-ai/dsh-session-projection', + '@deepseek-ai/dsh-shell-env', + '@deepseek-ai/dsh-skill', + '@deepseek-ai/dsh-skill-filesystem', + '@deepseek-ai/dsh-subagent', + '@deepseek-ai/dsh-subagent-spawn-in-process', + '@deepseek-ai/dsh-subprocess-local', + '@deepseek-ai/dsh-token-meter', + '@deepseek-ai/dsh-tool-subagent-report', + // Named by presets/*/agent.cordis.yml — the AGENT plane. `dsh-agent-presets` + // resolves a preset row against the preset directory, which is inside this + // package, so these must resolve from here too. + '@deepseek-ai/dsh-agent-tool-presentation', + '@deepseek-ai/dsh-persona', + '@deepseek-ai/dsh-terminal', + '@deepseek-ai/dsh-terminal-bash', + '@deepseek-ai/dsh-tool-bash', + '@deepseek-ai/dsh-tool-bash-persistent', + '@deepseek-ai/dsh-tool-fs', + '@deepseek-ai/dsh-tool-fs-search', + '@deepseek-ai/dsh-tool-goal', + '@deepseek-ai/dsh-tool-jobs', + '@deepseek-ai/dsh-tool-skill', + '@deepseek-ai/dsh-tool-str-replace-editor', + '@deepseek-ai/dsh-tool-subagent', + '@deepseek-ai/dsh-tool-subagent-control', + '@deepseek-ai/dsh-tool-todo', + // Used only by the test suite. The Loader is a runtime dependency of the app + // (through dsh-app-boot), but the preset specs mount it themselves, so it has + // to resolve from here as well. + '@deepseek-ai/cordis-plugin-loader', + '@deepseek-ai/dsh-agent-loop', + '@deepseek-ai/dsh-agent-loop-testkit', +] + +/** Third-party packages taken from the harness checkout's own install. */ +const VENDOR_PACKAGES = [ + '@agentclientprotocol/sdk', + 'tsx', + 'typescript', + 'vitest', +] + +/** Directory globs (one level of `*` each) that hold harness workspace members. */ +const WORKSPACE_GLOBS = [ + 'vendor/*', + 'packages/*/*', + 'apps/*', + 'native/landlock-run', + 'native/landlock-run/packages/*', +] + +/** + * Find the harness checkout to link against. + * @param explicit - a `--repo` value or DSH_REPO, when supplied. + * @returns the absolute path of a directory that looks like deepseek-harness. + */ +function resolveHarnessRepo(explicit) { + const candidates = explicit === undefined + ? [ + resolve(PACKAGE_ROOT, '../../../deepseek-harness'), + resolve(PACKAGE_ROOT, '../../../../deepseek-harness'), + resolve(PACKAGE_ROOT, '../../../../../deepseek-harness'), + ] + : [resolve(process.cwd(), explicit)] + for (const candidate of candidates) { + try { + const manifest = JSON.parse(readFileSync(join(candidate, 'package.json'), 'utf8')) + if (manifest.name === '@deepseek-ai/dsh-root') return candidate + } catch { + // Not a harness checkout; try the next candidate. + } + } + throw new Error( + `no deepseek-harness checkout found (looked at ${candidates.join(', ')}); ` + + 'pass --repo or set DSH_REPO', + ) +} + +/** + * Index every workspace member of the harness checkout by package name. + * @param repo - the harness checkout root. + * @returns a map from package name to its absolute directory. + */ +function indexWorkspace(repo) { + const index = new Map() + const dirs = [] + for (const glob of WORKSPACE_GLOBS) { + const parts = glob.split('/') + let level = [repo] + for (const part of parts) { + const next = [] + for (const base of level) { + if (part !== '*') { + next.push(join(base, part)) + continue + } + let entries = [] + try { + entries = readdirSync(base, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith('.')) next.push(join(base, entry.name)) + } + } + level = next + } + dirs.push(...level) + } + for (const dir of dirs) { + try { + const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) + if (typeof manifest.name === 'string') index.set(manifest.name, dir) + } catch { + // Not a package directory. + } + } + return index +} + +/** + * Replace `linkPath` with a symlink to `target`, creating scope directories. + * @param linkPath - absolute path of the link to (re)create. + * @param target - absolute path the link points at. + */ +function relink(linkPath, target) { + mkdirSync(dirname(linkPath), { recursive: true }) + rmSync(linkPath, { recursive: true, force: true }) + symlinkSync(target, linkPath, 'dir') +} + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { repo: { type: 'string' } }, + strict: true, +}) + +const repo = resolveHarnessRepo(values.repo ?? process.env.DSH_REPO) +const workspace = indexWorkspace(repo) +const nodeModules = join(PACKAGE_ROOT, 'node_modules') +const missing = [] + +for (const name of HARNESS_PACKAGES) { + const dir = workspace.get(name) + if (dir === undefined) { + missing.push(`${name} (no workspace member with that name in ${repo})`) + continue + } + relink(join(nodeModules, name), dir) +} + +for (const name of VENDOR_PACKAGES) { + const dir = join(repo, 'node_modules', name) + try { + statSync(dir) + } catch { + missing.push(`${name} (not installed at ${dir}; run pnpm install in the harness checkout)`) + continue + } + relink(join(nodeModules, name), dir) +} + +// The harness checkout's .bin shims are relative to their own realpath, so a +// single link gives this package working `vitest`/`tsx`/`tsc` executables. +relink(join(nodeModules, '.bin'), join(repo, 'node_modules', '.bin')) + +if (missing.length > 0) { + process.stderr.write(`link-local-dsh: ${missing.length} dependency(ies) could not be linked:\n`) + for (const line of missing) process.stderr.write(` - ${line}\n`) + process.exit(1) +} + +process.stdout.write( + `link-local-dsh: linked ${HARNESS_PACKAGES.length + VENDOR_PACKAGES.length} packages from ${repo}\n`, +) diff --git a/packages/dsh-acp/scripts/smoke.mjs b/packages/dsh-acp/scripts/smoke.mjs new file mode 100644 index 0000000000..ca374c5dd6 --- /dev/null +++ b/packages/dsh-acp/scripts/smoke.mjs @@ -0,0 +1,170 @@ +#!/usr/bin/env node +/** + * Boot smoke check: spawn the adapter exactly as an IDE would, complete the ACP + * handshake, open a session, and report what came back. + * + * This is the cheap half of end-to-end — it contacts no provider, because one + * is only reached on `session/prompt`. Pass `--prompt ""` to also drive + * one real turn and print every session update; that half needs a DeepSeek key + * the harness can resolve, which is any of `DEEPSEEK_API_KEY` in this + * environment, `$DSH_HOME/.credentials.yaml` (what dsh's Models page writes), + * or a `.env` fallback. This script asserts none of them: the credential chain + * belongs to the composition, and a missing key surfaces as a failed turn. + * + * Permission requests are answered `allow-once`, or `reject-once` with + * `--reject` — enough to exercise both branches of the approval path. + * + * `--cancel-after ` interrupts the turn mid-flight and then sends a second + * prompt, so a session that cancels but cannot continue fails loudly. + * + * `--mode ` sets the session's mode config option before that prompt, + * switching the agent preset. It stays in the credential-free half: a mode + * switch is answered by the bridge and never reaches the provider. + * + * `--load ` reopens a stored session instead of starting one, which + * is what an IDE does after a restart: the run then prints the replayed history + * and the mode the session comes back locked to. The id is a dsh session id — + * a directory name under `$DSH_HOME/acp-sessions//` — and `--cwd` must + * name the workspace it was created in. + * + * Usage: `node scripts/smoke.mjs [--mode code] [--load ] [--prompt "…"] [--reject] [--cancel-after 3000]` + */ + +import { spawn } from 'node:child_process' +import { dirname, resolve } from 'node:path' +import { Readable, Writable } from 'node:stream' +import { fileURLToPath } from 'node:url' +import { parseArgs } from 'node:util' +import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION } from '@agentclientprotocol/sdk' + +const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + prompt: { type: 'string' }, + load: { type: 'string' }, + mode: { type: 'string' }, + profile: { type: 'string' }, + cwd: { type: 'string' }, + reject: { type: 'boolean' }, + 'cancel-after': { type: 'string' }, + }, + strict: true, +}) + +/** Which option this run answers every permission request with. */ +const PERMISSION_CHOICE = values.reject === true ? 'reject-once' : 'allow-once' + +/** The workspace the session opens against — an IDE passes the user's project. */ +const WORKSPACE = resolve(process.cwd(), values.cwd ?? PACKAGE_ROOT) + +// Two launch routes, one composition. `--profile` is what BitFun ships: the +// user's installed `dsh` boots the materialized profile under $DSH_HOME, and +// every harness package resolves from that installation. Without it, this runs +// the working copy through tsx, which is the development loop. +const launch = values.profile === undefined + ? { command: process.execPath, args: ['--import', 'tsx', 'src/bin.ts', '--config', './cordis.yml'] } + : { command: 'dsh', args: ['--profile', values.profile] } + +const child = spawn( + launch.command, + launch.args, + { cwd: PACKAGE_ROOT, stdio: ['pipe', 'pipe', 'inherit'] }, +) + +const stream = ndJsonStream( + Writable.toWeb(child.stdin), + Readable.toWeb(child.stdout), +) + +/** Print one session update in a single readable line. */ +function describe(update) { + const kind = update.sessionUpdate + if (kind === 'tool_call' || kind === 'tool_call_update') { + const label = update.title === undefined ? '' : ` ${update.title}` + return `${kind} ${update.toolCallId}${label} [${update.status ?? '-'}]` + } + if (kind === 'agent_message_chunk' || kind === 'agent_thought_chunk') { + return `${kind} ${JSON.stringify(update.content.text ?? '')}` + } + if (kind === 'plan') return `plan ${update.entries.map(entry => `${entry.status}:${entry.content}`).join(' | ')}` + if (kind === 'config_option_update') { + return `config_option_update ${describeOptions(update.configOptions)}` + } + return kind +} + +/** Print a config option set the way a client's picker would show it. */ +function describeOptions(configOptions) { + if (configOptions === undefined || configOptions.length === 0) return '(none)' + return configOptions.map(option => { + const values = option.type === 'select' + ? option.options.map(value => (value.value === option.currentValue ? `[${value.value}]` : value.value)).join(' ') + : String(option.currentValue) + return `${option.id}(${option.category ?? '-'}): ${values}` + }).join(' | ') +} + +const client = new ClientSideConnection(() => ({ + sessionUpdate(params) { + process.stdout.write(` update: ${describe(params.update)}\n`) + return Promise.resolve() + }, + requestPermission(params) { + process.stdout.write( + ` permission: ${params.toolCall.toolCallId} (${params.toolCall.title ?? '?'}) -> ${PERMISSION_CHOICE}\n`, + ) + return Promise.resolve({ outcome: { outcome: 'selected', optionId: PERMISSION_CHOICE } }) + }, +}), stream) + +let failed = false +try { + const initialized = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + process.stdout.write(`initialize: ${JSON.stringify(initialized.agentInfo)} v${initialized.protocolVersion}\n`) + process.stdout.write(`loadSession capability: ${String(initialized.agentCapabilities?.loadSession === true)}\n`) + + // `--load` is the reopen path an IDE takes on restart: the session comes back + // from storage with its history replayed as updates and its mode already + // fixed, instead of a blank session under the roster default. + const session = values.load === undefined + ? await client.newSession({ cwd: WORKSPACE, mcpServers: [] }) + : { sessionId: values.load, ...await client.loadSession({ sessionId: values.load, cwd: WORKSPACE, mcpServers: [] }) } + const sessionId = session.sessionId + process.stdout.write(`${values.load === undefined ? 'newSession' : 'loadSession'}: ${sessionId}\n`) + process.stdout.write(`modes: ${describeOptions(session.configOptions)}\n`) + + if (values.mode !== undefined) { + const switched = await client.setSessionConfigOption({ + sessionId, + configId: 'agent-preset', + value: values.mode, + }) + process.stdout.write(`mode ${values.mode}: ${describeOptions(switched.configOptions)}\n`) + } + + if (values.prompt !== undefined) { + const pending = client.prompt({ sessionId, prompt: [{ type: 'text', text: values.prompt }] }) + if (values['cancel-after'] !== undefined) { + await new Promise(resolve => setTimeout(resolve, Number(values['cancel-after']))) + process.stdout.write('cancel: sent\n') + await client.cancel({ sessionId }) + } + const result = await pending + process.stdout.write(`prompt: ${result.stopReason}\n`) + + if (values['cancel-after'] !== undefined) { + // The point of the cancel probe: the session must still take work. + const next = await client.prompt({ sessionId, prompt: [{ type: 'text', text: 'say OK and nothing else' }] }) + process.stdout.write(`prompt after cancel: ${next.stopReason}\n`) + } + } +} catch (error) { + failed = true + process.stdout.write(`FAILED: ${String(error)}\n`) +} + +child.stdin.end() +await new Promise(resolve => child.once('exit', resolve)) +process.exit(failed ? 1 : 0) diff --git a/packages/dsh-acp/src/app.ts b/packages/dsh-acp/src/app.ts new file mode 100644 index 0000000000..03a7b3d03e --- /dev/null +++ b/packages/dsh-acp/src/app.ts @@ -0,0 +1,200 @@ +/** + * The IDE-facing ACP server app: the default agent spine + * ({@link @deepseek-ai/dsh-agent-spine-demo}), JSONL session persistence, and the + * {@link @bitfun/dsh-acp/bridge} protocol bridge, owned through one ordered + * lifecycle so ACP sessions quiesce before persistence detaches. + * + * Forked from `@deepseek-ai/dsh-acp-demo` + * (deepseek-harness/packages/examples/acp-demo/src/index.ts, MIT). + * Copyright (c) DeepSeek. See ../NOTICE.md. The only change is the transport: + * this app mounts the IDE bridge instead of the automation-only one. + * + * It writes nothing to stdout, pre-creates no agents, and leaves adapters, + * executors, and optional tools to the leaf `cordis.yml`, which must likewise + * avoid stdout loggers. Named exports are required so Loader retains this + * plugin's `Config` schema. + * + * @module @bitfun/dsh-acp + */ + +import type { Context } from '@deepseek-ai/cordis' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import z from '@deepseek-ai/schemastery' +import AgentPresets from '@deepseek-ai/dsh-agent-presets' +import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' +import * as workspaceContext from '@deepseek-ai/dsh-agent-instructions' +import ToolRuntime, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' +import JsonlSessionPersistence, { + JsonlCompressionSchema, + type JsonlCompression, +} from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy' +import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite' +import * as acp from './bridge.ts' + +export const name = 'acp-ide-app' +const DEFAULT_PERSISTENCE_ROOT = './.sessions' +const DEFAULT_PRESET = 'standard' + +/** + * The shipped preset roster: `/presets`, resolved from this module + * rather than from `process.cwd()`. An ACP client launches the adapter with the + * USER's workspace as the working directory, so a relative root would look for + * presets inside whatever project happens to be open. + * + * `../presets` is correct from both `src/app.ts` and the built `lib/app.js`. + */ +const SHIPPED_PRESET_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../presets') + +/** + * App config: the swappable per-deployment values. `provider` and `model` + * configure each agent the ACP bridge creates at `session/new`; `persona` is the + * deployment persona (forwarded to the system-prompt plugin); `toolOrder` is the + * explicit model-facing tool order; `tools` is the tool registry's config; + * `persistenceRoot` is the JSONL backend's directory. + */ +export interface Config { + /** + * Provider route for ACP-created agents. Omitted, each session starts on the + * deployment's default selection (`ctx.agentDefaultModel`), which is what the + * harness's own settings document configures. + */ + provider?: string + /** Model name for ACP-created agents (must have a registered adapter); see {@link Config.provider}. */ + model?: string + /** Publish model reasoning as `agent_thought_chunk` updates. Defaults to `true`. */ + reasoning?: boolean + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ + maxParallelToolCalls?: number + /** Deployment persona (the system-prompt plugin's `persona` config). */ + persona?: string + /** Explicit model-facing tool order (the system-prompt plugin's `toolOrder` config). */ + toolOrder?: string[] + /** Tool-registry config — its presentation `mode` (forwarded through agent-spine-demo). */ + tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string + /** Fallback session-title limits forwarded through agent-spine-demo. */ + sessionTitle?: NonNullable + /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ + persistenceRoot?: string + /** + * Preset an ACP session composes when the client picks none. Must name a + * directory under {@link SHIPPED_PRESET_ROOT} or the user preset root. + * Defaults to `standard`. + */ + defaultPreset?: string + /** + * Also scan `/.agent-presets`, so a user can drop in their own mode + * beside the shipped three. Defaults to `true`. + */ + userPresets?: boolean + /** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `true`. */ + packChunks?: boolean + /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ + persistenceCompression?: JsonlCompression + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] + /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ + skills?: agentCore.SkillConfig + /** Model-facing bash tool config forwarded through agent-core. */ + toolBash?: NonNullable + /** Process-local background-job admission config forwarded through agent-core. */ + jobs?: NonNullable + /** Generic background-job controls forwarded through agent-core; set false to omit their tools. */ + toolJobs?: NonNullable + /** Persisted same-session goals; owner defaults enable them, or false disables the stack and tools. */ + goals?: agentCore.GoalConfig | false +} + +export const Config: z = z.object({ + provider: z.string(), + model: z.string(), + reasoning: z.boolean().default(true), + maxParallelToolCalls: z.number().step(1).min(1), + persona: z.string(), + // The array default is forced to undefined: ABSENT means "lexicographic + // order" (the owning dsh-system-prompt schema does the same), while + // schemastery's native [] default would read as an invalid configured list. + toolOrder: z.array(z.string()).default(undefined as unknown as string[]), + tools: ToolRuntime.Config, + dshHome: z.string(), + sessionTitle: agentCore.SessionTitleConfigSchema, + persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), + defaultPreset: z.string().default(DEFAULT_PRESET), + userPresets: z.boolean().default(true), + packChunks: z.boolean().default(true), + persistenceCompression: JsonlCompressionSchema, + workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), + skills: agentCore.SkillConfigSchema, + toolBash: agentCore.ToolBashConfigSchema, + jobs: agentCore.JobsConfigSchema, + toolJobs: z.union([z.const(false), agentCore.ToolJobsConfigSchema]), + goals: z.union([z.const(false), agentCore.GoalConfigSchema]), +}) + +/** + * Compose the spine with the IDE ACP transport. The agent-spine-demo bundle + * pre-creates NO agents (its `agents` list defaults to `[]`) and carries the + * deployment `persona`; the JSONL backend and derived query index persist under + * `persistenceRoot`; the ACP bridge owns stdout for JSON-RPC and creates one + * agent per `session/new` from the provider/model pair. The composite effect + * unloads in reverse order, keeping checkpoint and persistence listeners + * attached until ACP agents have flushed their closing events. No logger, no + * `hmr` — stdout stays pure. + * @param ctx - the Cordis context this app mounts into. + * @param config - the deployment's provider, model, persona, and persistence selection. + */ +export async function apply(ctx: Context, config: Config): Promise { + const goals = config.goals ?? {} + const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT + // A client that closes this process's stdin has ended the conversation: the + // IDE quit, or it tore the adapter down. This lives in the app rather than in + // a bin script because `dsh --profile` boots the composition directly and + // never runs one — both launch routes must shut down the same way. Disposing + // the ROOT (not just this plugin) drains the whole tree in reverse, so ACP + // sessions quiesce before persistence detaches. + ctx.effect(() => { + const close = (): void => { + void ctx.root.fiber.dispose().then(() => { process.exit(0) }) + } + process.stdin.on('end', close) + return () => void process.stdin.off('end', close) + }, 'acp-ide.stdin-close') + await ctx.effect(async function* () { + const spine = ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }) + await spine + yield spine.dispose + // Before the transport, because the bridge composes every agent it creates + // from a preset and a missing roster would leave each session on the empty + // global tool layer. + const presets = ctx.plugin(AgentPresets, { + default: config.defaultPreset ?? DEFAULT_PRESET, + roots: [{ path: SHIPPED_PRESET_ROOT, trust: 'system' }], + includeUserRoot: config.userPresets ?? true, + }) + await presets + yield presets.dispose + const persistence = ctx.plugin(JsonlSessionPersistence, { + root: persistenceRoot, + ...config.packChunks !== undefined ? { packChunks: config.packChunks } : {}, + ...config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }, + }) + await persistence + yield persistence.dispose + const checkpoint = ctx.plugin(sessionCheckpointPolicy) + await checkpoint + yield checkpoint.dispose + const query = ctx.plugin(SqliteSessionQueryEngine, { path: join(persistenceRoot, 'session-query.db') }) + await query + yield query.dispose + const transport = ctx.plugin(acp, { + ...config.provider === undefined ? {} : { provider: config.provider }, + ...config.model === undefined ? {} : { model: config.model }, + ...config.reasoning === undefined ? {} : { reasoning: config.reasoning }, + }) + await transport + yield transport.dispose + }, 'acp-ide.composition') +} diff --git a/packages/dsh-acp/src/bin.ts b/packages/dsh-acp/src/bin.ts new file mode 100644 index 0000000000..043ef154d7 --- /dev/null +++ b/packages/dsh-acp/src/bin.ts @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/** + * Boot an IDE-facing ACP stdio server from `cordis.yml`; usage is + * `dsh-acp-ide [--config path]`, defaulting to `./cordis.yml`. Shared env + * loading, Loader guards, snapshot config selection, and settled-tree boot live + * in dsh-app-boot. Stdout is reserved for JSON-RPC, so diagnostics go only to + * stderr. + * + * Forked from `@deepseek-ai/dsh-acp-demo/bin` + * (deepseek-harness/packages/examples/acp-demo/src/bin.ts, MIT). + * Copyright (c) DeepSeek. See ../NOTICE.md. + * + * @module @bitfun/dsh-acp/bin + */ + +import { parseArgs } from 'node:util' +import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' + +const NAME = 'dsh-acp-ide' + +installFailLoud(NAME) +const snapshotMode = process.env['DSH_SNAPSHOT'] +if (snapshotMode !== 'replay') loadEnv(NAME) +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { config: { type: 'string', short: 'c' } }, + strict: true, +}) +await boot(NAME, resolveConfigPath(values.config ?? './cordis.yml', snapshotMode)) +// Shutdown on stdin close belongs to the app plugin, not to this script: the +// same composition also boots under `dsh --profile`, which runs no bin at all. diff --git a/packages/dsh-acp/src/bridge.ts b/packages/dsh-acp/src/bridge.ts new file mode 100644 index 0000000000..d1f36d22ab --- /dev/null +++ b/packages/dsh-acp/src/bridge.ts @@ -0,0 +1,951 @@ +/** + * IDE-oriented Agent Client Protocol server over JSON-RPC stdio. + * + * Forked from `@deepseek-ai/dsh-acp` + * (deepseek-harness/packages/acp/acp/src/index.ts, MIT). + * Copyright (c) DeepSeek. See ../NOTICE.md. + * + * The upstream bridge is automation-only by design: it carries prompt text, + * committed assistant text, cancellation, and one-shot permission decisions, + * and deliberately keeps tool calls, reasoning, and plans off the wire as + * "presentation or trace data". An IDE is exactly the client that needs that + * presentation data, so this fork keeps upstream's session, turn, and teardown + * machinery verbatim and replaces only the `session/event` dispatch with a full + * translation to ACP session updates. + * + * @module @bitfun/dsh-acp/bridge + */ + +import type { Context } from '@deepseek-ai/cordis' +import { randomUUID } from 'node:crypto' +import { isAbsolute } from 'node:path' +import { Readable, Writable } from 'node:stream' +import Schema from '@deepseek-ai/schemastery' +import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' +import { + AgentSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + RequestError, + type Agent as AcpAgent, + type AuthenticateRequest, + type CancelNotification, + type InitializeRequest, + type InitializeResponse, + type LoadSessionRequest, + type LoadSessionResponse, + type NewSessionRequest, + type NewSessionResponse, + type PlanEntry, + type PromptRequest, + type PromptResponse, + type SessionConfigOption, + type SessionConfigSelectOption, + type SessionNotification, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type StopReason, + type Stream, + type ToolCallUpdate, +} from '@agentclientprotocol/sdk' +import type { Agent } from '@deepseek-ai/dsh-agent' +// The roster itself is read through `ctx.get`; only the stored-preset resolver +// is a value here. This import also carries the `agent-preset/selected` +// session-event declaration the switch below logs and the resolver reads back. +import { resolveSessionPreset, type AgentPreset } from '@deepseek-ai/dsh-agent-presets' +import { + SessionId, type SessionEvent, type SessionHeader, type TurnEndReason, +} from '@deepseek-ai/dsh-session' +// Side-effect type import: declaration-merges the approval waterfall answered below. +import type {} from '@deepseek-ai/dsh-user-approval' +import { acpPromptToText, promptHasUnsupportedContent, turnEndToStopReason } from './codec.ts' +import { parseToolArguments, toolKind, toolLocations, toolOutcome } from './tool-view.ts' + +export const name = 'acp-ide' +/** The bridge creates and owns agents; every other concern is carried by the agent composition. */ +export const inject = ['agents'] + +/** + * The single continuable-subagent teardown the bridge needs. Declared + * structurally so this package does not depend on the subagent seam for one + * shutdown hook; an absent service means nothing continuable was materialized. + */ +interface ContinuableDrain { + /** + * Close admission below exact host-owned parents, then dispose only their + * continuable descendants child-first. + */ + drainContinuableDescendants(parents: readonly Agent[]): Promise +} + +/** + * The deployment's default model selection, declared structurally for the same + * reason as {@link ContinuableDrain}: the bridge reads it when its own config + * pins no provider/model, but does not depend on the settings seam to exist. A + * composition without the service falls back to this plugin's config. + */ +interface DefaultModelSource { + /** The provider/model a new agent should start on, live at call time. */ + currentSelection(): { provider?: string; model?: string } +} + +/** + * The stored-session reads `session/load` needs, declared structurally for the + * same reason as {@link ContinuableDrain}. A deployment composed without + * durable persistence simply has nothing to load, and the bridge answers that + * as a protocol error rather than depending on the storage seam to exist. + */ +interface SessionArchive { + /** + * One stored session's header and full event log. This is the authoritative + * read rather than a listing: it waits for a just-disposed session's final + * drain, which is exactly the session a client reopens first. + */ + inspect(id: SessionId): Promise<{ + readonly meta: SessionHeader + readonly events: readonly SessionEvent[] + }> +} + +/** Preserve invalid-parameter detail in the SDK wire error message. */ +function invalidParams(detail: string): RequestError { + return RequestError.invalidParams(undefined, detail) +} + +/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */ +function internalError(detail: string): RequestError { + return RequestError.internalError(undefined, detail) +} + +/** Plugin config: the provider/model selection used for each ACP-created agent. */ +export interface AcpConfig { + /** Provider route for created agents; omitted defers to the deployment default. */ + provider?: string + /** Model name for created agents; omitted defers to the deployment default. */ + model?: string + /** + * Publish `agent_thought_chunk` updates for model reasoning. Clients that + * render reasoning inline want this; leave it off for a quieter wire. + * Defaults to `true`. + */ + reasoning?: boolean + /** Runtime-only transport override; production uses stdio. */ + stream?: Stream +} + +export const Config: Schema = Schema.object({ + provider: Schema.string(), + model: Schema.string(), + reasoning: Schema.boolean().default(true), +}) + +/** What this bridge already told the client about one tool call. */ +interface PublishedCall { + title: string + rawInput: unknown +} + +/** Per-session protocol state. */ +interface SessionRecord { + agent: Agent + /** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */ + dispose: () => Promise + /** + * `turn:step` keys whose committed assistant text was already streamed as + * deltas, so the committed message is not published a second time. + */ + streamedText: Set + /** The same de-duplication ledger for reasoning. */ + streamedReasoning: Set + /** + * Tool calls this bridge has published a `tool_call` update for, so the + * approval hook can tell a client-visible call from one it must announce. + */ + published: Map + /** + * The agent preset this session is composed from, absent when the deployment + * composes no roster. Tracked here so the mode picker can report what is + * already running without re-reading the scope chain. + */ + presetId: string | undefined + /** + * Tail of this session's preset switches. Two `session/set_config_option` + * requests must not recompose concurrently: the second one's blank-session + * check has to see the first one's result, not the state it raced past. + */ + presetSwitch: Promise | undefined + /** Whether the client has been told the mode is now fixed, so it is said once. */ + presetLockPublished: boolean + /** In-flight prompt and its captured turn number for exact settlement. */ + inflight: { + resolve: (reason: StopReason) => void + reject: (error: Error) => void + messageId: string + turn: number | undefined + /** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */ + endReason: TurnEndReason | undefined + } | undefined +} + +/** Key a streaming ledger entry by the model round that produced it. */ +function stepKey(turn: number, step: number): string { + return `${turn}:${step}` +} + +/** + * The session config option the preset roster is offered under. + * + * ACP has no command channel to the agent — a client's slash menu inserts the + * command as ordinary prompt text, so a mode chosen that way arrives glued to + * the user's own message. A config option of category `mode` is the channel the + * spec provides for exactly this: the client renders it as a picker next to its + * composer and sends the choice on its own request. + */ +const PRESET_CONFIG_ID = 'agent-preset' + +/** + * Why the picker is down to one entry, in the client's own words. + * + * Doubles as the error a late `session/set_config_option` is refused with, so a + * client that asks anyway is told the same thing its UI already showed. + */ +const PRESET_LOCKED = 'This conversation has already started, so its mode is fixed. ' + + 'Start a new session to pick another mode.' + +/** + * Whether a session may still change its preset. + * + * A started conversation's history was produced under its preset's tools, so + * swapping now would leave logged tool calls the new composition cannot make. + * The roster states the rule and leaves the check to its caller. + * @param session - the agent's session. + * @returns true while no turn has started. + */ +function sessionBlank(session: Agent['session']): boolean { + return !session.events.some(event => event.type === 'turn/start') +} + +/** + * One roster entry as a selectable value of the mode option. + * @param preset - the roster entry. + * @returns its id as the value, with whatever prose the preset carries. + */ +function presetValue(preset: AgentPreset): SessionConfigSelectOption { + return { + value: preset.id, + name: preset.name ?? preset.id, + ...preset.description === undefined ? {} : { description: preset.description }, + } +} + +/** + * Mount the IDE-facing ACP server. + * @param ctx - Cordis context carrying the agent factory and session events. + * @param config - Initial provider/model selection and optional test transport. + */ +export function apply(ctx: Context, config: AcpConfig): void { + // ACP handlers execute outside this plugin's injection scope, so capture the + // injected service during apply rather than reading it lazily in a callback. + const agents = ctx.agents + // The preset roster is optional. A deployment that keeps every model-facing + // plugin in its host composition has no modes to offer, and this bridge then + // behaves exactly as it did before they existed. + const presets = ctx.get('agentPresets') + const logger = ctx.logger + const sessions = new Map() + const publishReasoning = config.reasoning ?? true + let closed = false + let conn: AgentSideConnection + + /** Return the bridge-owned record for an agent, rejecting same-id impostors. */ + const ownedRecord = (agent: Agent): SessionRecord | undefined => { + const record = sessions.get(agent.session.id) + return record?.agent === agent ? record : undefined + } + + const assertOpen = (): void => { + if (closed) throw internalError('the ACP bridge has been disposed') + } + + const requireSession = (sessionId: SessionId): SessionRecord => { + const record = sessions.get(sessionId) + if (record === undefined) throw invalidParams(`unknown session: ${sessionId}`) + return record + } + + /** Send a protocol update without letting a disconnected client fail an agent turn. */ + const notify = (notification: SessionNotification): void => { + /* v8 ignore next 3 -- only a transport write failure reaches this guard. */ + void conn.sessionUpdate(notification).catch((error: unknown) => { + logger.warn(`acp: session/update failed: ${String(error)}`) + }) + } + + /** Send one update for a bridge-owned session. */ + const update = (record: SessionRecord, payload: SessionNotification['update']): void => { + notify({ sessionId: record.agent.session.id, update: payload }) + } + + /** + * The session's mode picker: the roster as one `select` config option. + * + * Broken presets are withheld — the roster keeps them so their directory + * stays visible to an author, but offering one would advertise a choice every + * attempt refuses. A locked session is published with only the mode in force, + * which is the client-generic way to say "this cannot be changed": a client + * disables a picker it has nothing left to pick from, and an agent that does + * allow mid-conversation switching keeps its full list and stays switchable. + * @param record - the session. + * @param locked - whether the mode is already fixed; read off the session by default. + * @returns the one option, or nothing when this deployment composes no roster. + */ + const presetOptions = async ( + record: SessionRecord, locked = !sessionBlank(record.agent.session), + ): Promise => { + if (presets === undefined) return [] + const roster = await presets.list() + const current = record.presetId + const offered = roster + .filter(preset => preset.broken === undefined) + .filter(preset => !locked || preset.id === current) + const currentValue = current ?? offered[0]?.id + if (currentValue === undefined || offered.length === 0) return [] + return [{ + type: 'select', + id: PRESET_CONFIG_ID, + name: 'Mode', + category: 'mode', + ...locked ? { description: PRESET_LOCKED } : {}, + currentValue, + options: offered.map(presetValue), + }] + } + + const settlePrompt = (record: SessionRecord, reason: StopReason): void => { + const inflight = record.inflight + if (inflight === undefined) return + record.inflight = undefined + inflight.resolve(reason) + } + + const rejectFromError = ( + inflight: NonNullable, + reason: Extract, + ): void => { + inflight.reject(internalError(`turn failed: ${reason.error.message}`)) + } + + /** + * Announce a tool call to the client. Idempotent per call id so the approval + * hook can safely re-announce a call whose `tool/call` event it did not see. + */ + const publishToolCall = ( + record: SessionRecord, callId: string, toolName: string, rawInput: unknown, + ): PublishedCall => { + const existing = record.published.get(callId) + if (existing !== undefined) return existing + const published: PublishedCall = { title: toolName, rawInput } + record.published.set(callId, published) + update(record, { + sessionUpdate: 'tool_call', + toolCallId: callId, + // The harness tool name IS the title: a client matches it against its own + // tool vocabulary to render a native card, and a prettified label would + // only make that match guess. + title: toolName, + kind: toolKind(toolName), + status: 'pending', + rawInput, + locations: toolLocations(rawInput), + }) + return published + } + + /** + * Translate one harness session event to the ACP wire. Everything the + * upstream automation bridge withholds — streaming text, reasoning, tool + * calls, tool results, and plans — is published here. + */ + const publishEvent = (record: SessionRecord, event: SessionEvent): void => { + switch (event.type) { + case 'assistant/chunk': { + const { turn, step, chunk } = event.data + const key = stepKey(turn, step) + if (chunk.type === 'text-delta') { + if (chunk.text.length === 0) return + record.streamedText.add(key) + update(record, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: chunk.text } }) + } else if (chunk.type === 'reasoning-delta') { + if (chunk.text.length === 0) return + record.streamedReasoning.add(key) + if (publishReasoning) { + update(record, { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: chunk.text } }) + } + } + return + } + + case 'assistant/message': { + const { turn, step, message } = event.data + const key = stepKey(turn, step) + // A step that streamed deltas already published this text; only a + // non-streaming route (or a replayed round) reaches the client here. + const emitText = !record.streamedText.has(key) + const emitReasoning = publishReasoning && !record.streamedReasoning.has(key) + for (const block of message.content) { + if (block.type === 'text' && block.text.length > 0 && emitText) { + update(record, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: block.text } }) + } else if (block.type === 'reasoning' && block.text.length > 0 && emitReasoning) { + update(record, { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: block.text } }) + } else if (block.type === 'image') { + update(record, { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: `[image attachment ${block.attachment.attachmentId}]` }, + }) + } + } + return + } + + case 'tool/call': { + const { callId, name: toolName, arguments: args } = event.data + publishToolCall(record, callId, toolName, parseToolArguments(args)) + return + } + + case 'tool/result': { + const outcome = toolOutcome(event.data.message, event.data.error !== undefined) + if (outcome.callId === undefined) return + record.published.delete(outcome.callId) + update(record, { + sessionUpdate: 'tool_call_update', + toolCallId: outcome.callId, + status: outcome.isError ? 'failed' : 'completed', + content: outcome.content, + rawOutput: event.data.message as unknown, + }) + return + } + + case 'todo/write': { + const entries: PlanEntry[] = event.data.todos.map(todo => ({ + content: todo.content, + // The harness todo list carries no priority; ACP requires one, and a + // flat `medium` is the honest rendering of "unranked". + priority: 'medium', + status: todo.status, + })) + update(record, { sessionUpdate: 'plan', entries }) + return + } + + case 'turn/start': { + // The first turn fixes the mode: from here on the history was produced + // under this composition's tools, and a later swap would leave logged + // tool calls the new composition cannot make. Republishing the picker + // shrunk to the mode in force is how the client learns that — said once, + // because every later turn would say the same thing. + if (presets === undefined || record.presetLockPublished) return + record.presetLockPublished = true + // Locked is asserted rather than read: whether this very event is + // already in `session.events` is the session's business, not the + // bridge's, and the answer here is known. + void presetOptions(record, true).then(configOptions => { + if (closed || configOptions.length === 0) return + if (sessions.get(record.agent.session.id) !== record) return + update(record, { sessionUpdate: 'config_option_update', configOptions }) + }, (error: unknown) => { + logger.warn(`acp: failed to publish the fixed agent preset: ${String(error)}`) + }) + return + } + + case 'step/end': { + const key = stepKey(event.data.turn, event.data.step) + record.streamedText.delete(key) + record.streamedReasoning.delete(key) + return + } + + case 'turn/end': { + record.streamedText.clear() + record.streamedReasoning.clear() + record.published.clear() + return + } + + default: + // Every other harness event is internal bookkeeping with no ACP + // counterpart; a client reconstructs round structure from the updates + // above. + return + } + } + + /** + * Republish a resumed session's history so the client renders the + * conversation it is reopening. + * + * Replay is the client's only source for a loaded session: `session/load` + * returns no transcript, so everything the IDE shows above the composer comes + * from these updates. What is withheld is what would double up or lie — + * `assistant/chunk` deltas are already committed into `assistant/message`, + * and `turn/start` would announce a mode lock the load response itself + * carries. Non-user `user/message` entries (plugin reminders, tool feedback) + * are agent-internal input, not something the user typed. + * @param record - the resumed session, already registered. + */ + const replayHistory = (record: SessionRecord): void => { + for (const event of record.agent.session.events) { + try { + if (event.type === 'user/message') { + if (event.data.source.kind !== 'user') continue + for (const block of event.data.content) { + if (block.type !== 'text' || block.text.length === 0) continue + update(record, { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: block.text } }) + } + continue + } + if (event.type === 'assistant/chunk' || event.type === 'turn/start') continue + publishEvent(record, event) + } catch (error: unknown) { + // One unrenderable historical event costs its card, not the reopened + // conversation. + logger.warn(`acp: failed to replay ${event.type}: ${String(error)}`) + } + } + } + + ctx.on('session/event', (session, event: SessionEvent) => { + const record = sessions.get(session.header.id) + if (record === undefined || record.agent.session !== session) return + try { + publishEvent(record, event) + } catch (error: unknown) { + // A presentation-mapping failure must never break turn settlement below: + // the client loses one card, not the answer. + logger.warn(`acp: failed to publish ${event.type}: ${String(error)}`) + } finally { + const inflight = record.inflight + if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { + if (event.data.reason.kind === 'error') { + // Model failures surface immediately as prompt errors; ordinary + // endings wait for whole-agent idle below. + record.inflight = undefined + rejectFromError(inflight, event.data.reason) + } else { + inflight.endReason = event.data.reason + } + } + } + }) + + ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => { + const record = ownedRecord(agent) + const inflight = record?.inflight + if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn + }) + + ctx.on('agent/error', ({ agent, turn, error }) => { + const record = ownedRecord(agent) + const inflight = record?.inflight + if (record === undefined || inflight === undefined || inflight.turn === turn) return + record.inflight = undefined + inflight.reject(internalError(`turn failed: ${errorChain(error)}`)) + }) + + // Permission requests reach the client attached to a tool call it has already + // been shown. The harness appends `tool/call` before the scheduler prepares + // the call (agent-loop `tool-calls.ts`), so the card exists by the time this + // runs; `publishToolCall` re-announces it anyway rather than trusting that + // ordering, because a permission prompt against an unknown call id is exactly + // the failure that leaves an IDE waiting for a card that never arrives. + ctx.on('approval/request', (request, next) => { + const record = ownedRecord(request.agent) + if (record === undefined || request.callId === undefined) return next() + const callId = request.callId + const published = publishToolCall(record, callId, request.toolName, {}) + const toolCall: ToolCallUpdate = { + toolCallId: callId, + title: published.title, + kind: toolKind(published.title), + status: 'pending', + rawInput: published.rawInput, + } + return conn.requestPermission({ + sessionId: record.agent.session.id, + toolCall, + options: [ + { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' }, + ], + }).then(({ outcome }) => { + if (outcome.outcome === 'cancelled') return 'cancelled' + if (outcome.optionId !== 'allow-once') return 'rejected' + // Granted: the call is about to run, so move the card out of `pending`. + // `tool/result` settles it to completed or failed. + update(record, { sessionUpdate: 'tool_call_update', toolCallId: callId, status: 'in_progress' }) + return 'allowed-once' + }) + }) + + const makeAgent = (connection: AgentSideConnection): AcpAgent => { + conn = connection + return { + initialize(_params: InitializeRequest): Promise { + // Single-version agent: the spec's "same version if supported, else + // the latest supported" both resolve to this server's one version. + return Promise.resolve({ + protocolVersion: PROTOCOL_VERSION, + agentInfo: { name: 'dsh-acp-ide', version: '0.0.1' }, + agentCapabilities: { + // Reopening a conversation is the IDE's normal case, and the + // harness persists every session it runs: without this, a client + // has no way to say "continue that one" and starts a blank session + // whose history and mode are gone. + loadSession: true, + promptCapabilities: { image: false, audio: false, embeddedContext: false }, + }, + authMethods: [], + }) + }, + + authenticate(_params: AuthenticateRequest): Promise { + return Promise.resolve() + }, + + async newSession(params: NewSessionRequest): Promise { + assertOpen() + validateSessionParams(params) + const sessionId = SessionId(randomUUID()) + // Resolved before creation so the session header records the exact id + // that was composed, not the alias `undefined` stands for: the roster's + // default is a hot-reloaded setting, and a resumed session must rebuild + // the composition its turns actually ran under. + const composed = presets === undefined ? undefined : (await presets.resolve()).id + const handle = await agents.create({ + sessionId, + meta: { cwd: params.cwd, ...composed === undefined ? {} : { agentPreset: composed } }, + agentOptions: agentOptions(ctx, config), + // Composition belongs in `setup`, which the factory awaits before the + // agent is published: a preset that fails to mount rolls the whole + // creation back rather than yielding a session on the empty global + // tool layer. + ...presets === undefined || composed === undefined ? {} : { + setup: async (agentCtx: Context) => void await presets.mount(agentCtx, composed), + }, + }) + /* v8 ignore next 4 -- a real stdio close can race an in-flight create. */ + if (closed) { + await handle.dispose() + throw internalError('connection closed during session/new') + } + const record: SessionRecord = { + agent: handle.agent, + dispose: () => handle.dispose(), + streamedText: new Set(), + streamedReasoning: new Set(), + published: new Map(), + presetId: composed, + presetSwitch: undefined, + presetLockPublished: false, + inflight: undefined, + } + sessions.set(sessionId, record) + // Carried by the response rather than announced afterwards: a client + // registers its update handler for a session only once `session/new` + // has returned, so anything published before that can be dropped. + const configOptions = await presetOptions(record, false) + return { sessionId, ...configOptions.length === 0 ? {} : { configOptions } } + }, + + /** + * Reopen a persisted session: resume its agent and replay its history. + * + * The session is rebuilt from storage under the preset its turns actually + * ran on ({@link resolveSessionPreset} reads the log, not just the + * creation header), because a resumed conversation's tool calls have to + * stay ones the mounted composition can still make. That also settles the + * mode picker: a session with turns comes back locked, exactly as it was + * when the client last saw it. + * + * An unknown id is refused with an invalid-parameter error rather than + * silently answered with an empty session, so a client can fall back to + * `session/new` and know that is what happened. + */ + async loadSession(params: LoadSessionRequest): Promise { + assertOpen() + validateSessionParams(params) + const sessionId = SessionId(params.sessionId) + // Already live — a second load of the same session must not resume a + // rival agent on top of the running one. Replaying is still right: the + // client asked for the transcript. + const live = sessions.get(sessionId) + if (live !== undefined) { + replayHistory(live) + const configOptions = await presetOptions(live) + return configOptions.length === 0 ? {} : { configOptions } + } + + const archive = ctx.get('sessionPersistence') as SessionArchive | undefined + if (archive === undefined) throw invalidParams('this deployment persists no sessions') + // Absent, unreadable, and written-by-an-incompatible-format all mean the + // same thing to a client — this conversation cannot be reopened — and + // saying so as an invalid parameter is what lets it fall back to + // `session/new` knowing why. + const inspected = await archive.inspect(sessionId).catch((error: unknown) => { + throw invalidParams(`cannot load session ${sessionId}: ${errorChain(error)}`) + }) + if (inspected.meta.cwd !== params.cwd) { + throw invalidParams( + `session ${sessionId} belongs to "${inspected.meta.cwd ?? '(none)'}", not "${params.cwd}"`, + ) + } + const composed = presets === undefined + ? undefined + : resolveSessionPreset({ header: inspected.meta, events: inspected.events }) + const handle = await agents.resume({ + resumeSessionId: sessionId, + agentOptions: agentOptions(ctx, config), + ...presets === undefined || composed === undefined ? {} : { + setup: async (agentCtx: Context) => void await presets.mount(agentCtx, composed), + }, + }) + /* v8 ignore next 4 -- a real stdio close can race an in-flight resume. */ + if (closed) { + await handle.dispose() + throw internalError('connection closed during session/load') + } + const record: SessionRecord = { + agent: handle.agent, + dispose: () => handle.dispose(), + streamedText: new Set(), + streamedReasoning: new Set(), + published: new Map(), + presetId: composed, + presetSwitch: undefined, + // A session with turns is already locked, and the response below says + // so; only a session reopened before its first turn can still be + // told, by the `turn/start` that starts it. + presetLockPublished: !sessionBlank(handle.agent.session), + inflight: undefined, + } + sessions.set(sessionId, record) + replayHistory(record) + const configOptions = await presetOptions(record) + return configOptions.length === 0 ? {} : { configOptions } + }, + + /** + * Choose the session's mode. + * + * Refused once the conversation has started, for the same reason the + * picker is down to one entry by then: the turns already logged ran on the + * current preset's tools. + */ + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + assertOpen() + const record = requireSession(SessionId(params.sessionId)) + if (params.configId !== PRESET_CONFIG_ID) { + throw invalidParams(`unknown config option: ${params.configId}`) + } + if (presets === undefined) throw invalidParams('this deployment offers no modes') + if (typeof params.value !== 'string') { + throw invalidParams(`"${PRESET_CONFIG_ID}" is a select option, not a boolean`) + } + const id = params.value + const swap = async (): Promise => { + const target = (await presets.list()).find(preset => preset.id === id) + if (target === undefined) throw invalidParams(`unknown mode: ${id}`) + if (target.broken !== undefined) { + throw invalidParams(`mode "${id}" cannot be used: ${target.broken}`) + } + // Asking for the mode already in force is not a switch, so it is + // answered even on a locked session: it changes nothing either way. + if (record.presetId === target.id) return await presetOptions(record) + // Re-read inside the queue: a switch ahead of this one may have run, + // and a conversation may have started, since this request arrived. + if (!sessionBlank(record.agent.session)) throw invalidParams(PRESET_LOCKED) + try { + const preset = await presets.recompose(record.agent.ctx, target.id) + // Logged only after the swap committed, and appended rather than + // written back into the creation header: every later turn runs under + // this composition, so a resumed session must rebuild it rather than + // the one the session was created with. + record.agent.session.append('agent-preset/selected', { agentPreset: preset.id }) + record.presetId = preset.id + } catch (error: unknown) { + // A rejected recompose leaves the previous composition mounted, so + // the session stays usable and the user can pick again. + throw internalError(`failed to switch to "${id}": ${errorChain(error)}`) + } + return await presetOptions(record) + } + const queued = record.presetSwitch ?? Promise.resolve() + const turn = queued.then(swap) + record.presetSwitch = turn.then(() => undefined, () => undefined) + return { configOptions: await turn } + }, + + async prompt(params: PromptRequest): Promise { + assertOpen() + const record = requireSession(SessionId(params.sessionId)) + if (record.inflight !== undefined) { + throw invalidParams('a prompt is already in flight for this session') + } + if (promptHasUnsupportedContent(params.prompt)) { + throw invalidParams('only text and resource_link prompt content is supported') + } + const text = acpPromptToText(params.prompt) + if (text.trim().length === 0) throw invalidParams('empty prompt') + + // Not driving a retired agent is this bridge's contract: an + // agent-loop-only reload disposes the loop's agents while the bridge + // record survives, so validate the record against the live registry + // before sending — a disposed machine would accept the item silently. + if (ctx.agents.get(record.agent.id) !== record.agent) { + throw internalError('prompt was not queued: the agent was disposed outside the bridge') + } + const message = createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } }) + const stopReason = await new Promise((resolve, reject) => { + // Arm the slot before followup() so a listener-driven synchronous + // turn cannot slip past correlation; a synchronous followup() + // failure (invalid input) must free the slot again or the session + // would reject every later prompt as already in flight. + const inflight: NonNullable = { + resolve, reject, messageId: message.id, turn: undefined, endReason: undefined, + } + record.inflight = inflight + try { + record.agent.followup(message) + /* v8 ignore start -- future-proofing guard, see above */ + } catch (error: unknown) { + record.inflight = undefined + const detail = error instanceof Error ? error.message : String(error) + throw internalError(`prompt was not queued: ${detail}`) + } + /* v8 ignore stop */ + // Settlement waits for whole-agent idle: a correlated turn/end arms + // `endReason`, while a turnless slot (admission discarded the + // prompt) stays cancelled. Other producers may run further turns + // before quiescence; the prompt settles only when the agent stops. + void record.agent.whenIdle().then(() => { + if (record.inflight !== inflight) return + record.inflight = undefined + const end = inflight.endReason + if (end === undefined) { + inflight.resolve('cancelled') + } else { + // Token-limit and other non-terminal endings are not prompt-level + // stop reasons; only normal quiescence reports end_turn. + inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end)) + } + }) + }) + return { stopReason } + }, + + cancel(params: CancelNotification): Promise { + const record = sessions.get(SessionId(params.sessionId)) + if (record === undefined) return Promise.resolve() + record.agent.cancel({ kind: 'user' }) + settlePrompt(record, 'cancelled') + return Promise.resolve() + }, + } + } + + /* v8 ignore next 4 -- production stdio wiring; tests inject config.stream. */ + const stream: Stream = config.stream ?? ndJsonStream( + Writable.toWeb(process.stdout) as WritableStream, + Readable.toWeb(process.stdin) as ReadableStream, + ) + conn = new AgentSideConnection(makeAgent, stream) + + let quiescing: Promise | undefined + const quiesce = (): Promise => { + if (quiescing !== undefined) return quiescing + closed = true + const records = [...sessions.values()] + sessions.clear() + // Stop the bridge's own work before any await: a descendant drain can block + // on persistence or scoped cleanup, and the top-level agents must not keep + // running model and tool calls for its whole duration. + for (const record of records) { + record.agent.cancel({ kind: 'user' }) + settlePrompt(record, 'cancelled') + } + quiescing = (async () => { + // Continuable subagents outlive the turn that started them, and their + // Activations own descendant teardown. Drain only these sessions' forests + // child-first BEFORE disposing the top-level agents, so no descendant is + // left holding a runtime its owner already released. + const subagents = ctx.get('subagents') as ContinuableDrain | undefined + if (subagents !== undefined) { + try { + await subagents.drainContinuableDescendants(records.map(record => record.agent)) + } catch (error: unknown) { + logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`) + } + } + const disposals = await Promise.allSettled(records.map(record => record.dispose())) + const failures: unknown[] = [] + for (const result of disposals) { + if (result.status === 'rejected') failures.push(result.reason as unknown) + } + if (failures.length > 0) { + const detail = failures.map(failure => errorChain(failure)).join('; ') + throw new AggregateError( + failures, + `ACP agent teardown failed for ${failures.length} session(s): ${detail}`, + ) + } + })() + return quiescing + } + + /* v8 ignore start -- production transport rejection and teardown failure. */ + void conn.closed + .catch((error: unknown) => { + logger.warn(`acp: connection closed with an error: ${String(error)}`) + }) + .then(quiesce) + .catch((error: unknown) => { + logger.warn(`acp: connection-close teardown failed: ${String(error)}`) + }) + /* v8 ignore stop */ + + ctx.effect(() => quiesce, 'acp.connection') +} + +/** + * Build per-agent options without assigning absent optional fields. + * + * Plugin config wins where it is set; anything it leaves open comes from the + * deployment's default selection, read HERE rather than captured at `apply` so + * that a model chosen in the harness's settings after this process started + * takes effect on the next session. + * @param ctx - the plugin context, source of the optional default-model service. + * @param config - ACP provider/model configuration. + * @returns the resolved fields only. + */ +function agentOptions(ctx: Context, config: AcpConfig): { provider?: string; model?: string } { + const fallback = (ctx.get('agentDefaultModel') as DefaultModelSource | undefined)?.currentSelection() + const provider = config.provider ?? fallback?.provider + const model = config.model ?? fallback?.model + return { + ...provider !== undefined ? { provider } : {}, + ...model !== undefined ? { model } : {}, + } +} + +/** Reject session features outside this bridge's contract. */ +function validateSessionParams(params: NewSessionRequest | LoadSessionRequest): void { + if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`) + if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) { + throw invalidParams('additionalDirectories is not supported') + } + if (params.mcpServers.length > 0) throw invalidParams('mcpServers is not supported') +} diff --git a/packages/dsh-acp/src/codec.ts b/packages/dsh-acp/src/codec.ts new file mode 100644 index 0000000000..ea086993b5 --- /dev/null +++ b/packages/dsh-acp/src/codec.ts @@ -0,0 +1,70 @@ +/** + * Pure translation between the harness lifecycle and the ACP wire. + * + * Forked verbatim from `@deepseek-ai/dsh-acp` + * (deepseek-harness/packages/acp/acp/src/codec.ts, MIT). + * Copyright (c) DeepSeek. See ../NOTICE.md. + * + * @module @bitfun/dsh-acp/codec + */ + +import type { ContentBlock as AcpContentBlock, StopReason } from '@agentclientprotocol/sdk' +import type { TurnEndReason } from '@deepseek-ai/dsh-session' + +/** + * Map a harness turn ending to ACP's terminal reason vocabulary. + * @param reason - harness turn outcome. + * @returns the closest legal ACP stop reason. + */ +export function turnEndToStopReason(reason: TurnEndReason): StopReason { + switch (reason.kind) { + case 'completed': + return 'end_turn' + case 'max-tokens': + return 'max_tokens' + // `cancelled` is reserved for explicit client cancellation (`session/cancel`) + // and disposal, both settled out of band; a turn aborted by a hook or + // another owner is ordinary quiescence and reports `end_turn`. + case 'aborted': + return 'end_turn' + case 'interrupted': + return 'cancelled' + case 'blocked': + case 'error': + return 'end_turn' + default: + return 'end_turn' + } +} + +/** + * Flatten an ACP prompt's baseline blocks to text. Text blocks concatenate + * verbatim; resource links become explicit textual references so a baseline + * client can point at files without the bridge silently dropping that context. + * @param prompt - supported ACP prompt blocks. + * @returns text in wire order, with resource links rendered as bracketed references. + */ +export function acpPromptToText(prompt: readonly AcpContentBlock[]): string { + return prompt.flatMap((block): string[] => { + switch (block.type) { + case 'text': + return [block.text] + case 'resource_link': + return [`\n[resource_link name=${JSON.stringify(block.name)} uri=${JSON.stringify(block.uri)}]\n`] + default: + return [] + } + }).join('') +} + +/** + * Whether a prompt carries content beyond the ACP baseline. The spec requires + * every agent to accept `text` and `resource_link`; richer inline payloads + * (image, audio, embedded resource) are optional capabilities this bridge does + * not advertise, so they are rejected rather than silently dropped. + * @param prompt - ACP prompt blocks to inspect. + * @returns `true` when any block is neither `text` nor `resource_link`. + */ +export function promptHasUnsupportedContent(prompt: readonly AcpContentBlock[]): boolean { + return prompt.some(block => block.type !== 'text' && block.type !== 'resource_link') +} diff --git a/packages/dsh-acp/src/tool-view.ts b/packages/dsh-acp/src/tool-view.ts new file mode 100644 index 0000000000..a011fdabd2 --- /dev/null +++ b/packages/dsh-acp/src/tool-view.ts @@ -0,0 +1,159 @@ +/** + * Presentation mapping from harness tool vocabulary to the ACP tool-call shape + * an IDE renders. This is the part `@deepseek-ai/dsh-acp` deliberately omits: + * its wire is automation-only, so it publishes no tool identity at all. + * + * @module @bitfun/dsh-acp/tool-view + */ + +import type { ToolCallContent, ToolCallLocation, ToolKind } from '@agentclientprotocol/sdk' +import type { ContentBlock, ToolResultMessage } from '@deepseek-ai/dsh-llm' + +/** + * Harness tool name to the ACP kind vocabulary. Exact names only: a client's + * icon and grouping should never hinge on a substring accident, and an + * unrecognized tool is honestly `other` rather than mis-labelled. + */ +const TOOL_KINDS: Readonly> = { + ask_user_question: 'other', + bash: 'execute', + create_goal: 'other', + edit: 'edit', + exit_plan_mode: 'switch_mode', + get_goal: 'other', + glob: 'search', + grep: 'search', + interrupt_agent: 'other', + job_kill: 'other', + job_list: 'other', + job_output: 'other', + list_agents: 'other', + lsp: 'search', + pwsh: 'execute', + ralph: 'other', + read: 'read', + read_image: 'read', + report: 'other', + run_code: 'execute', + send_message: 'other', + session_event_read: 'search', + session_event_search: 'search', + session_event_trace: 'search', + session_search: 'search', + session_trace: 'search', + skill: 'other', + str_replace_editor: 'edit', + subagent: 'other', + subagent_fork: 'other', + terminal_close: 'execute', + terminal_list: 'other', + terminal_open: 'execute', + terminal_read: 'read', + terminal_send: 'execute', + terminal_signal: 'execute', + todo_write: 'other', + update_goal: 'other', + web_fetch: 'fetch', + web_search: 'search', + workflow: 'other', + write: 'edit', +} + +/** + * Classify a harness tool for the client's tool card. + * @param name - the harness tool name as registered with `ctx.tools`. + * @returns the ACP kind, defaulting to `other` for tools this bridge has not seen. + */ +export function toolKind(name: string): ToolKind { + return TOOL_KINDS[name] ?? 'other' +} + +/** + * Decode the model's raw argument string. The harness carries arguments as the + * provider emitted them, so a truncated or malformed call must still produce a + * card: an unparseable payload is surfaced as the literal text instead of + * failing the update. + * @param args - the raw JSON string from `tool/call`. + * @returns the parsed object, or the original string when it is not JSON. + */ +export function parseToolArguments(args: string): unknown { + if (args.length === 0) return {} + try { + return JSON.parse(args) + } catch { + return args + } +} + +/** Argument keys, in priority order, that name the file a tool acts on. */ +const PATH_KEYS = ['file_path', 'path', 'notebook_path', 'filePath'] + +/** + * Extract the file locations a tool call touches so the client can offer + * "follow along" navigation. + * @param input - the parsed tool arguments. + * @returns one location when the arguments name a path, otherwise none. + */ +export function toolLocations(input: unknown): ToolCallLocation[] { + if (typeof input !== 'object' || input === null) return [] + const record = input as Record + for (const key of PATH_KEYS) { + const value = record[key] + if (typeof value !== 'string' || value.length === 0) continue + const line = record['offset'] ?? record['line'] + return [{ path: value, ...typeof line === 'number' ? { line } : {} }] + } + return [] +} + +/** The harness tool-result payload a client can render, lifted out of the message. */ +export interface ToolOutcome { + /** The call this result settles, taken from the result block or the message source. */ + callId: string | undefined + /** Renderable result blocks in wire order. */ + content: ToolCallContent[] + /** Whether the tool reported failure, from the result block or the event's error field. */ + isError: boolean +} + +/** + * Render one harness content block for a tool card. + * @param block - a block from the tool-result payload. + * @returns the ACP content entry, or undefined for blocks with no client rendering. + */ +function renderBlock(block: ContentBlock): ToolCallContent | undefined { + switch (block.type) { + case 'text': + return { type: 'content', content: { type: 'text', text: block.text } } + case 'reasoning': + return { type: 'content', content: { type: 'text', text: block.text } } + case 'image': + return { + type: 'content', + content: { type: 'text', text: `[image attachment ${block.attachment.attachmentId}]` }, + } + default: + return undefined + } +} + +/** + * Lift the renderable outcome out of a harness `tool/result` payload. + * @param message - the tool-result message the harness appended. + * @param hasError - whether the event carried a structured error alongside the message. + * @returns the call correlation, rendered content, and failure flag. + */ +export function toolOutcome(message: ToolResultMessage, hasError: boolean): ToolOutcome { + const block = message.content.find(entry => entry.type === 'tool-result') + const content = block === undefined + ? [] + : block.content.flatMap(entry => { + const rendered = renderBlock(entry) + return rendered === undefined ? [] : [rendered] + }) + return { + callId: block?.toolCallId ?? message.source.callId, + content, + isError: hasError || block?.isError === true, + } +} diff --git a/packages/dsh-acp/tests/approval-order.spec.ts b/packages/dsh-acp/tests/approval-order.spec.ts new file mode 100644 index 0000000000..be72b24bc4 --- /dev/null +++ b/packages/dsh-acp/tests/approval-order.spec.ts @@ -0,0 +1,113 @@ +/** + * A permission prompt must never reference a tool call the client has not been + * shown — that mismatch is what leaves an IDE waiting for a card that never + * arrives, and then for a turn that never settles. + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import { makeBridgeHarness, waitForEvents, type BridgeHarness, type OrderedEvent } from './harness.ts' + +/** The tool-call ids each timeline entry refers to, for order assertions. */ +function timeline(events: readonly OrderedEvent[]): string[] { + return events.map((event) => { + if (event.kind === 'permission') return `permission:${event.request.toolCall.toolCallId}` + if (event.update.sessionUpdate === 'tool_call') return `tool_call:${event.update.toolCallId}` + if (event.update.sessionUpdate === 'tool_call_update') { + return `tool_call_update:${event.update.toolCallId}:${event.update.status ?? ''}` + } + return event.update.sessionUpdate + }) +} + +describe('ACP approval ordering', () => { + let harness: BridgeHarness | undefined + + afterEach(async () => { + await harness?.dispose() + harness = undefined + }) + + /** Open a session with the approval waterfall mounted. */ + async function openSession(): Promise { + harness = await makeBridgeHarness() + await harness.ctx.plugin(ApprovalService) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(SessionId(sessionId)) + if (agent === undefined) throw new Error('missing agent') + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('step/start', { turn: 1, step: 0 }) + return agent + } + + it('shows the tool card before asking permission for it', async () => { + const agent = await openSession() + const callId = CallId('call-approve') + // The agent loop appends tool/call before the scheduler prepares the call, + // so this is the real production order. + agent.session.append('tool/call', { + turn: 1, step: 0, callId, name: 'bash', arguments: JSON.stringify({ command: 'rm -rf build' }), + }) + await waitForEvents(harness!, 1) + harness!.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) + + await expect(harness!.ctx.approval.request({ agent, toolName: 'bash', callId })).resolves.toBe('allowed-once') + + expect(timeline(harness!.orderedEvents)).toEqual([ + 'tool_call:call-approve', + 'permission:call-approve', + 'tool_call_update:call-approve:in_progress', + ]) + expect(harness!.permissionRequests[0]).toMatchObject({ + sessionId: agent.session.id, + toolCall: { toolCallId: 'call-approve', title: 'bash', kind: 'execute', status: 'pending' }, + options: [ + { optionId: 'allow-once', kind: 'allow_once' }, + { optionId: 'reject-once', kind: 'reject_once' }, + ], + }) + expect(harness!.permissionRequests[0]?.toolCall.rawInput).toEqual({ command: 'rm -rf build' }) + }) + + it('announces a call the client was never told about before asking', async () => { + const agent = await openSession() + harness!.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'reject-once' } }) + + await expect(harness!.ctx.approval.request({ agent, toolName: 'write', callId: CallId('call-unseen') })) + .resolves.toBe('rejected') + + // Rejected calls stay pending: the harness reports no tool/result for them. + expect(timeline(harness!.orderedEvents)).toEqual(['tool_call:call-unseen', 'permission:call-unseen']) + expect(harness!.updates[0]).toMatchObject({ + sessionUpdate: 'tool_call', toolCallId: 'call-unseen', title: 'write', kind: 'edit', status: 'pending', + }) + }) + + it('announces the call once across the event and the approval hook', async () => { + const agent = await openSession() + const callId = CallId('call-once') + agent.session.append('tool/call', { + turn: 1, step: 0, callId, name: 'edit', arguments: JSON.stringify({ file_path: '/repo/a.ts' }), + }) + await waitForEvents(harness!, 1) + harness!.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) + await harness!.ctx.approval.request({ agent, toolName: 'edit', callId }) + + expect(timeline(harness!.orderedEvents).filter(entry => entry.startsWith('tool_call:'))) + .toEqual(['tool_call:call-once']) + // The re-announcement must not overwrite the arguments the event carried. + expect(harness!.permissionRequests[0]?.toolCall.rawInput).toEqual({ file_path: '/repo/a.ts' }) + }) + + it('leaves foreign and identity-less requests to the rest of the waterfall', async () => { + const agent = await openSession() + await expect(harness!.ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('unavailable') + expect(harness!.permissionRequests).toHaveLength(0) + expect(harness!.updates).toHaveLength(0) + }) +}) diff --git a/packages/dsh-acp/tests/fixtures/plugins/contribute.js b/packages/dsh-acp/tests/fixtures/plugins/contribute.js new file mode 100644 index 0000000000..20157bb599 --- /dev/null +++ b/packages/dsh-acp/tests/fixtures/plugins/contribute.js @@ -0,0 +1,17 @@ +// A preset row: registers one model-facing tool named from config, so a test +// can read a session's composition off its tool list. +// +// Import-free on purpose — the Loader resolves a composition row through Node's +// ESM resolver, which cannot see this package's TypeScript sources. +export const name = 'contribute' +export const inject = ['tools'] + +export function apply(ctx, config) { + ctx.effect(() => ctx.tools.register({ + name: config.tool, + description: `fixture tool ${config.tool}`, + parameters: { type: 'object', properties: {}, additionalProperties: false }, + output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }, + execute: () => Promise.resolve(config.tool), + })) +} diff --git a/packages/dsh-acp/tests/fixtures/presets/alpha/agent.cordis.yml b/packages/dsh-acp/tests/fixtures/presets/alpha/agent.cordis.yml new file mode 100644 index 0000000000..a614459c27 --- /dev/null +++ b/packages/dsh-acp/tests/fixtures/presets/alpha/agent.cordis.yml @@ -0,0 +1,5 @@ +# The default fixture mode: one tool, named after the preset. +- id: alpha-tool + name: ../../plugins/contribute.js + config: + tool: alpha diff --git a/packages/dsh-acp/tests/fixtures/presets/alpha/preset.yml b/packages/dsh-acp/tests/fixtures/presets/alpha/preset.yml new file mode 100644 index 0000000000..baad117978 --- /dev/null +++ b/packages/dsh-acp/tests/fixtures/presets/alpha/preset.yml @@ -0,0 +1,3 @@ +name: Alpha mode +description: the fixture default +order: 1 diff --git a/packages/dsh-acp/tests/fixtures/presets/beta/agent.cordis.yml b/packages/dsh-acp/tests/fixtures/presets/beta/agent.cordis.yml new file mode 100644 index 0000000000..f863e47626 --- /dev/null +++ b/packages/dsh-acp/tests/fixtures/presets/beta/agent.cordis.yml @@ -0,0 +1,5 @@ +# The mode a test switches TO: a disjoint tool set, so a recompose is visible. +- id: beta-tool + name: ../../plugins/contribute.js + config: + tool: beta diff --git a/packages/dsh-acp/tests/fixtures/presets/beta/preset.yml b/packages/dsh-acp/tests/fixtures/presets/beta/preset.yml new file mode 100644 index 0000000000..fd31deadb2 --- /dev/null +++ b/packages/dsh-acp/tests/fixtures/presets/beta/preset.yml @@ -0,0 +1,3 @@ +name: Beta mode +description: the fixture alternative +order: 2 diff --git a/packages/dsh-acp/tests/fixtures/presets/broken/preset.yml b/packages/dsh-acp/tests/fixtures/presets/broken/preset.yml new file mode 100644 index 0000000000..5e40795aeb --- /dev/null +++ b/packages/dsh-acp/tests/fixtures/presets/broken/preset.yml @@ -0,0 +1,6 @@ +# A roster row the deployment must fix: the directory occupies the id but +# carries no `agent.cordis.yml`, so discovery reports it broken. It exists to +# prove the mode picker withholds a choice every attempt would refuse. +name: Broken mode +description: the fixture nobody can compose +order: 3 diff --git a/packages/dsh-acp/tests/harness.ts b/packages/dsh-acp/tests/harness.ts new file mode 100644 index 0000000000..7ec8a50c8f --- /dev/null +++ b/packages/dsh-acp/tests/harness.ts @@ -0,0 +1,281 @@ +/** + * In-memory ACP transport fixture over the real agent factory and loop. + * + * Adapted from `deepseek-harness/packages/acp/acp/tests/harness.ts` (MIT, + * copyright (c) DeepSeek); see ../NOTICE.md. The only substantive change is the + * plugin under test — `../src/bridge.ts` instead of `@deepseek-ai/dsh-acp` — + * plus `orderedEvents`, which records session updates and permission requests + * on one timeline so a test can assert their relative order. + */ + +import { dirname, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import AgentPresets from '@deepseek-ai/dsh-agent-presets' +import { + ClientSideConnection, + ndJsonStream, + type Agent as AcpAgent, + type Client, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type Stream, +} from '@agentclientprotocol/sdk' +import { type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as AcpPlugin from '../src/bridge.ts' +import type { AcpConfig } from '../src/bridge.ts' + +/** Scripted adapter for protocol tests. */ +class MockAdapter extends LlmAdapter { + readonly requests: GenerateOptions[] = [] + private readonly script: (StreamChunk[] | 'hang')[] + + constructor(script: (StreamChunk[] | 'hang')[]) { + super() + this.script = script + } + + override providerInfo(provider: string) { + if (provider !== 'mock') throw new Error(`MockAdapter: unknown provider ${provider}`) + return { id: 'mock', name: 'Mock' } + } + + override listModels(provider: string) { + return Promise.resolve(provider === 'mock' ? [{ provider: 'mock', id: 'mock', name: 'Mock' }] : []) + } + + async * stream(options: GenerateOptions): AsyncIterable { + this.requests.push(options) + const entry = this.script.shift() + if (entry === undefined) throw new Error('MockAdapter: script exhausted') + if (entry === 'hang') { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'partial' } + await new Promise((_resolve, reject) => { + if (options.signal?.aborted) { + reject(new Error('aborted')) + return + } + options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true }) + }) + return + } + for (const chunk of entry) { + if (options.signal?.aborted) throw new Error('aborted') + yield chunk + } + } +} + +/** Scripted text response ending in a clean stop. */ +export function textResponse(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + ...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })), + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'usage', usage: { inputTokens: 5, outputTokens: text.length } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +/** + * The fixture preset roster: `alpha` and `beta`, each contributing one tool + * named after itself, so a test can read a session's composition off its tool + * list and see a recompose replace it. + */ +export const PRESET_FIXTURES = join(dirname(fileURLToPath(import.meta.url)), 'fixtures', 'presets') + +/** Scripted response that fails after publishing an uncommitted partial chunk. */ +export function errorResponse(message: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'partial' }, + { type: 'finish', reason: { kind: 'error', failure: { message, code: 'PROVIDER_ERROR' } } }, + ] +} + +export type CapturedUpdate = SessionNotification['update'] + +/** + * One entry on the client-observed timeline. Session updates and permission + * requests arrive over the same connection, so recording them together is the + * only way to assert that a tool card precedes the prompt that references it. + */ +export type OrderedEvent = + | { kind: 'update'; update: CapturedUpdate } + | { kind: 'permission'; request: RequestPermissionRequest } + +export interface BridgeHarness { + ctx: Context + client: ClientSideConnection + adapter: MockAdapter + updates: CapturedUpdate[] + sessionUpdates: { sessionId: string; update: CapturedUpdate }[] + permissionRequests: RequestPermissionRequest[] + /** Updates and permission requests interleaved in arrival order. */ + orderedEvents: OrderedEvent[] + onPermission: (request: RequestPermissionRequest) => RequestPermissionResponse + closeClientTransport: () => Promise + acpFiber: Awaited> + /** The AgentLoop fiber, so a test can reload the loop out from under the bridge. */ + loopFiber: Awaited> + dispose: () => Promise +} + +type AcpConfigOverrides = { [K in keyof AcpConfig]?: AcpConfig[K] | undefined } + +/** Build the bridge and a connected SDK client over cross-wired byte streams. */ +export async function makeBridgeHarness(options: { + script?: (StreamChunk[] | 'hang')[] + config?: AcpConfigOverrides + persona?: string + /** + * Mount the {@link PRESET_FIXTURES} roster with this default, so the bridge + * composes sessions from a preset and offers the modes as a session config + * option. Omitted, no roster is published and the bridge takes its no-modes + * path. + */ + presets?: { default: string } + /** + * Mount durable JSONL persistence under this root, so a session outlives its + * agent and `session/load` has something to resume. Omitted, the deployment + * persists nothing and the bridge takes its no-archive path. + */ + persistenceRoot?: string +} = {}): Promise { + const adapter = new MockAdapter(options.script ?? []) + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' } }) + // Before the loop, as the app composes it: the coordinator captures a + // session's header from `session/created`, which the first agent emits. + if (options.persistenceRoot !== undefined) { + await ctx.plugin(JsonlSessionPersistence, { root: options.persistenceRoot }) + } + const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) + ctx.llm.registerAdapter(['mock'], adapter) + if (options.presets !== undefined) { + // Before the bridge: it captures the roster during `apply`, exactly as the + // app's composition orders them. + ctx.baseUrl = pathToFileURL(PRESET_FIXTURES).href + '/' + await ctx.plugin(Loader) + await ctx.plugin(AgentPresets, { + default: options.presets.default, + roots: [{ path: PRESET_FIXTURES, trust: 'system' as const }], + includeUserRoot: false, + }) + } + + const agentToClient = new TransformStream() + const clientToAgent = new TransformStream() + const clientToAgentWriter = clientToAgent.writable.getWriter() + const clientOutput = new WritableStream({ + write: chunk => clientToAgentWriter.write(chunk), + }) + const agentStream: Stream = ndJsonStream(agentToClient.writable, clientToAgent.readable) + const clientStream: Stream = ndJsonStream(clientOutput, agentToClient.readable) + + const updates: CapturedUpdate[] = [] + const sessionUpdates: { sessionId: string; update: CapturedUpdate }[] = [] + const permissionRequests: RequestPermissionRequest[] = [] + const orderedEvents: OrderedEvent[] = [] + const harness: BridgeHarness = { + ctx, + adapter, + updates, + sessionUpdates, + permissionRequests, + orderedEvents, + onPermission: () => ({ outcome: { outcome: 'cancelled' } }), + client: undefined as unknown as ClientSideConnection, + acpFiber: undefined as unknown as BridgeHarness['acpFiber'], + loopFiber, + closeClientTransport: async () => { await clientToAgentWriter.close() }, + dispose: async () => { await ctx.fiber.dispose() }, + } + + const makeClient = (_agent: AcpAgent): Client => ({ + sessionUpdate(params: SessionNotification): Promise { + updates.push(params.update) + sessionUpdates.push({ sessionId: params.sessionId, update: params.update }) + orderedEvents.push({ kind: 'update', update: params.update }) + return Promise.resolve() + }, + requestPermission(params: RequestPermissionRequest): Promise { + permissionRequests.push(params) + orderedEvents.push({ kind: 'permission', request: params }) + return Promise.resolve(harness.onPermission(params)) + }, + }) + + const config = { stream: agentStream, ...options.config } as AcpConfig + if (!(options.config && 'provider' in options.config)) config.provider = 'mock' + if (!(options.config && 'model' in options.config)) config.model = 'mock' + harness.acpFiber = await ctx.plugin({ + name: 'acp-ide-test', + inject: [...AcpPlugin.inject], + apply: (inner: Context) => { AcpPlugin.apply(inner, config) }, + }) + harness.client = new ClientSideConnection(makeClient, clientStream) + return harness +} + +/** + * Wait until the client has observed at least `count` timeline entries. + * @param harness - the fixture whose client timeline is polled. + * @param count - the number of entries to wait for. + */ +export async function waitForEvents(harness: BridgeHarness, count: number): Promise { + const deadline = Date.now() + 2000 + while (harness.orderedEvents.length < count) { + if (Date.now() > deadline) { + throw new Error(`timed out waiting for ${count} client events (saw ${harness.orderedEvents.length})`) + } + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +/** + * Wait until the client has observed `count` session updates of one kind. + * + * `waitForEvents` counts everything the session has produced, so a finished + * prompt already satisfies it. That makes it useless for waiting on a + * notification the bridge publishes off the back of an event rather than as + * part of the prompt reply — the wait returns at once and the assertion races + * the notification. Wait for the notification itself instead. + * @param harness - the fixture whose updates are polled. + * @param kind - the `sessionUpdate` discriminant to count. + * @param count - the number of such updates to wait for. + */ +export async function waitForUpdates( + harness: BridgeHarness, kind: K, count: number, +): Promise { + const deadline = Date.now() + 2000 + while (updatesOfKind(harness, kind).length < count) { + if (Date.now() > deadline) { + throw new Error( + `timed out waiting for ${count} ${kind} update(s) ` + + `(saw ${updatesOfKind(harness, kind).length})`, + ) + } + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +/** + * Collect every session update of one kind that the client has observed. + * @param harness - the fixture to read. + * @param kind - the `sessionUpdate` discriminator to keep. + * @returns the matching updates in arrival order. + */ +export function updatesOfKind( + harness: BridgeHarness, kind: K, +): Extract[] { + return harness.updates.filter( + (update): update is Extract => update.sessionUpdate === kind, + ) +} diff --git a/packages/dsh-acp/tests/lifecycle.spec.ts b/packages/dsh-acp/tests/lifecycle.spec.ts new file mode 100644 index 0000000000..a173ec6152 --- /dev/null +++ b/packages/dsh-acp/tests/lifecycle.spec.ts @@ -0,0 +1,79 @@ +/** + * Regression cover for the upstream machinery this fork keeps verbatim: prompt + * settlement, cancellation, and the single in-flight slot. Where behavior + * intentionally diverges — a streaming client sees deltas, including a failed + * turn's partial text — the test says so. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { SessionId } from '@deepseek-ai/dsh-session' +import { errorResponse, makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' + +/** All published assistant text, concatenated in arrival order. */ +function messageText(harness: BridgeHarness): string { + return harness.updates.flatMap(update => ( + update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text' + ? [update.content.text] + : [] + )).join('') +} + +describe('ACP prompt lifecycle', () => { + let harness: BridgeHarness | undefined + + afterEach(async () => { + await harness?.dispose() + harness = undefined + }) + + async function newSession(): Promise { + if (harness === undefined) throw new Error('missing harness') + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + return (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId + } + + it('streams a whole turn and settles it as end_turn', async () => { + harness = await makeBridgeHarness({ script: [textResponse('answer')] }) + const sessionId = await newSession() + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + await vi.waitFor(() => { expect(messageText(harness!)).toBe('answer') }) + // Character deltas, not one committed blob: this is the streaming an IDE + // renders live, and the commit must not duplicate it. + expect(harness.updates.filter(update => update.sessionUpdate === 'agent_message_chunk')).toHaveLength(6) + }) + + it('rejects a failed turn but keeps the partial text it already streamed', async () => { + harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] }) + const sessionId = await newSession() + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })) + .rejects.toThrow(/turn failed: provider boom/) + // Deliberate divergence from the automation bridge, which withholds every + // uncommitted chunk: a live client has already shown these characters, and + // the prompt rejection is what tells it the turn failed. + expect(messageText(harness)).toBe('partial') + }) + + it('permits one in-flight prompt and continues the session after a cancel', async () => { + harness = await makeBridgeHarness({ script: ['hang', textResponse('second')] }) + const sessionId = await newSession() + const first = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'one' }] }) + await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(sessionId))?.status).toBe('running') }) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] })) + .rejects.toThrow(/already in flight/) + + await harness.client.cancel({ sessionId }) + await expect(first).resolves.toEqual({ stopReason: 'cancelled' }) + await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'again' }] })) + .resolves.toEqual({ stopReason: 'end_turn' }) + await vi.waitFor(() => { expect(messageText(harness!)).toContain('second') }) + }) + + it('refuses a prompt for an unknown session', async () => { + harness = await makeBridgeHarness() + await newSession() + await expect(harness.client.prompt({ sessionId: 'nope', prompt: [{ type: 'text', text: 'go' }] })) + .rejects.toThrow(/unknown session/) + }) +}) diff --git a/packages/dsh-acp/tests/preset-modes.spec.ts b/packages/dsh-acp/tests/preset-modes.spec.ts new file mode 100644 index 0000000000..9f26103090 --- /dev/null +++ b/packages/dsh-acp/tests/preset-modes.spec.ts @@ -0,0 +1,172 @@ +/** + * Modes: the preset roster a session is composed from, offered to the client as + * a `mode` session config option and fixed once the conversation starts. + * + * The switch is driven the way a client's composer picker drives it — through + * `session/set_config_option` — and the result is read off the session's tool + * list rather than off anything the bridge reports about itself. + */ + +import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION, type SessionConfigOption } from '@agentclientprotocol/sdk' +import { SessionId } from '@deepseek-ai/dsh-session' +import { + makeBridgeHarness, textResponse, updatesOfKind, waitForUpdates, type BridgeHarness, +} from './harness.ts' + +/** The tools a session's model can call, i.e. what its preset composed. */ +function toolNames(harness: BridgeHarness, sessionId: string): string[] { + const agent = harness.ctx.agents.get(SessionId(sessionId)) + if (agent === undefined) throw new Error(`no agent for session ${sessionId}`) + return harness.ctx.tools.schemas(agent).map(schema => schema.name).sort() +} + +/** Every preset the session has been switched to, in order. */ +function selections(harness: BridgeHarness, sessionId: string): unknown[] { + const agent = harness.ctx.agents.get(SessionId(sessionId)) + if (agent === undefined) throw new Error(`no agent for session ${sessionId}`) + return agent.session.events + .filter(event => event.type === 'agent-preset/selected') + .map(event => (event as { data: { agentPreset: string } }).data.agentPreset) +} + +/** The one mode option, asserted to be a select so its values can be read. */ +function modeOption(configOptions: SessionConfigOption[] | null | undefined): Extract< + SessionConfigOption, { type: 'select' } +> { + const [option, ...rest] = configOptions ?? [] + expect(rest).toEqual([]) + if (option?.type !== 'select') throw new Error(`expected one select option, got ${JSON.stringify(configOptions)}`) + return option +} + +/** The values a client's picker would list, in roster order. */ +function modeValues(configOptions: SessionConfigOption[] | null | undefined): string[] { + return modeOption(configOptions).options.map(value => ('value' in value ? value.value : value.group)) +} + +describe('agent-preset modes', () => { + let harness: BridgeHarness | undefined + + afterEach(async () => { + await harness?.dispose() + harness = undefined + }) + + it('composes the default preset and offers the roster with session/new', async () => { + harness = await makeBridgeHarness({ presets: { default: 'alpha' } }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const session = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + expect(toolNames(harness, session.sessionId)).toEqual(['alpha']) + const option = modeOption(session.configOptions) + expect(option.id).toBe('agent-preset') + expect(option.category).toBe('mode') + expect(option.currentValue).toBe('alpha') + // `broken` is withheld: offering it would advertise a choice every attempt + // refuses. Nothing says the mode is fixed yet, because it is not. + expect(option.options).toEqual([ + { value: 'alpha', name: 'Alpha mode', description: 'the fixture default' }, + { value: 'beta', name: 'Beta mode', description: 'the fixture alternative' }, + ]) + expect(option.description).toBeUndefined() + }) + + it('recomposes a blank session, without a model turn', async () => { + harness = await makeBridgeHarness({ presets: { default: 'alpha' } }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const result = await harness.client.setSessionConfigOption({ + sessionId, configId: 'agent-preset', value: 'beta', + }) + + // The exchange is a control, not a turn: the model is never asked. + expect(harness.adapter.requests).toHaveLength(0) + expect(toolNames(harness, sessionId)).toEqual(['beta']) + expect(selections(harness, sessionId)).toEqual(['beta']) + // The response carries the whole set, so the picker re-renders from it. + expect(modeOption(result.configOptions).currentValue).toBe('beta') + expect(modeValues(result.configOptions)).toEqual(['alpha', 'beta']) + }) + + it('answers a repeat of the session\'s own mode without recomposing', async () => { + harness = await makeBridgeHarness({ presets: { default: 'alpha' } }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + const result = await harness.client.setSessionConfigOption({ + sessionId, configId: 'agent-preset', value: 'alpha', + }) + + expect(modeOption(result.configOptions).currentValue).toBe('alpha') + expect(selections(harness, sessionId)).toEqual([]) + expect(toolNames(harness, sessionId)).toEqual(['alpha']) + }) + + it('refuses an option, a mode, and a value type it does not have', async () => { + harness = await makeBridgeHarness({ presets: { default: 'alpha' } }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await expect(harness.client.setSessionConfigOption({ + sessionId, configId: 'thought-level', value: 'high', + })).rejects.toThrow(/unknown config option: thought-level/) + await expect(harness.client.setSessionConfigOption({ + sessionId, configId: 'agent-preset', value: 'gamma', + })).rejects.toThrow(/unknown mode: gamma/) + await expect(harness.client.setSessionConfigOption({ + sessionId, configId: 'agent-preset', value: 'broken', + })).rejects.toThrow(/cannot be used/) + await expect(harness.client.setSessionConfigOption({ + sessionId, configId: 'agent-preset', type: 'boolean', value: true, + })).rejects.toThrow(/select option, not a boolean/) + + expect(toolNames(harness, sessionId)).toEqual(['alpha']) + expect(selections(harness, sessionId)).toEqual([]) + }) + + it('fixes the mode once the conversation has started, and says so once', async () => { + harness = await makeBridgeHarness({ + presets: { default: 'alpha' }, + script: [textResponse('hi'), textResponse('again')], + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'hello' }] }) + + await expect(harness.client.setSessionConfigOption({ + sessionId, configId: 'agent-preset', value: 'beta', + })).rejects.toThrow(/already started/) + + expect(toolNames(harness, sessionId)).toEqual(['alpha']) + expect(selections(harness, sessionId)).toEqual([]) + // The picker was shrunk to the mode in force, which is how a client learns + // to disable it without knowing anything about presets. + await waitForUpdates(harness, 'config_option_update', 1) + const [locked, ...repeats] = updatesOfKind(harness, 'config_option_update') + expect(modeValues(locked?.configOptions)).toEqual(['alpha']) + expect(modeOption(locked?.configOptions).description).toMatch(/already started/) + + // A second turn does not say it again. + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'more' }] }) + expect(repeats).toEqual([]) + expect(updatesOfKind(harness, 'config_option_update')).toHaveLength(1) + }) + + it('offers nothing when the deployment has no roster', async () => { + harness = await makeBridgeHarness({ script: [textResponse('ok')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const session = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + expect(session.configOptions ?? []).toEqual([]) + await expect(harness.client.setSessionConfigOption({ + sessionId: session.sessionId, configId: 'agent-preset', value: 'beta', + })).rejects.toThrow(/offers no modes/) + + // A turn still runs, and still announces nothing about modes. + await harness.client.prompt({ sessionId: session.sessionId, prompt: [{ type: 'text', text: 'hi' }] }) + expect(harness.adapter.requests).toHaveLength(1) + expect(updatesOfKind(harness, 'config_option_update')).toEqual([]) + }) +}) diff --git a/packages/dsh-acp/tests/round-trip.spec.ts b/packages/dsh-acp/tests/round-trip.spec.ts new file mode 100644 index 0000000000..a1fb8a8ea7 --- /dev/null +++ b/packages/dsh-acp/tests/round-trip.spec.ts @@ -0,0 +1,116 @@ +/** + * The whole path in one test: a model asks for a tool, the loop appends + * `tool/call`, the scheduler asks for approval, the tool runs, and the client + * sees a card that opens before the prompt and closes after the result. + * + * The hand-driven specs assert each mapping in isolation; this one is the only + * place the ORDER is a fact about the production loop rather than about how a + * test appended events. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import ApprovalService from '@deepseek-ai/dsh-user-approval' +import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' + +/** One scripted model step that calls `write` and stops for its result. */ +const CALL_WRITE: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { + type: 'block-end', + index: 0, + block: { + type: 'tool-call', + id: CallId('call-write'), + name: 'write', + arguments: JSON.stringify({ file_path: '/repo/notes.md', content: 'hi' }), + }, + }, + { type: 'finish', reason: { kind: 'tool-calls' } }, +] + +describe('ACP tool-call round trip', () => { + let harness: BridgeHarness | undefined + + afterEach(async () => { + await harness?.dispose() + harness = undefined + }) + + it('opens the card, asks, runs, and closes it — in that order', async () => { + harness = await makeBridgeHarness({ script: [CALL_WRITE, textResponse('written')] }) + await harness.ctx.plugin(ApprovalService) + harness.ctx.tools.register(defineContentToolFixture({ + name: 'write', + description: 'write a file', + parameters: { file_path: { type: 'string' }, content: { type: 'string' } }, + execute: () => Promise.resolve([{ type: 'text', text: 'wrote 2 bytes' }]), + })) + // The production ask path: the scheduler waterfalls `tools/pre-execute` + // before dispatch, and an `ask` decision routes through ctx.approval. + harness.ctx.on('tools/pre-execute', () => Promise.resolve({ kind: 'ask', reason: 'test policy' })) + harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) + + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'write notes' }] }) + expect(result.stopReason).toBe('end_turn') + + await vi.waitFor(() => { + expect(harness!.orderedEvents.some(event => event.kind === 'update' + && event.update.sessionUpdate === 'tool_call_update' + && event.update.status === 'completed')).toBe(true) + }) + + const trace = harness.orderedEvents.flatMap((event) => { + if (event.kind === 'permission') return [`permission:${event.request.toolCall.toolCallId}`] + const update = event.update + if (update.sessionUpdate === 'tool_call') return [`open:${update.toolCallId}:${update.status ?? ''}`] + if (update.sessionUpdate === 'tool_call_update') return [`${update.status}:${update.toolCallId}`] + return [] + }) + expect(trace).toEqual([ + 'open:call-write:pending', + 'permission:call-write', + 'in_progress:call-write', + 'completed:call-write', + ]) + + const permission = harness.permissionRequests[0] + expect(permission?.toolCall).toMatchObject({ title: 'write', kind: 'edit' }) + expect(permission?.toolCall.rawInput).toEqual({ file_path: '/repo/notes.md', content: 'hi' }) + + const completed = harness.updates.find(update => update.sessionUpdate === 'tool_call_update' + && update.status === 'completed') + expect(completed).toMatchObject({ + content: [{ type: 'content', content: { type: 'text', text: 'wrote 2 bytes' } }], + }) + }) + + it('closes the card as failed when the user rejects the call', async () => { + harness = await makeBridgeHarness({ script: [CALL_WRITE, textResponse('stopped')] }) + await harness.ctx.plugin(ApprovalService) + harness.ctx.tools.register(defineContentToolFixture({ + name: 'write', + description: 'write a file', + parameters: { file_path: { type: 'string' }, content: { type: 'string' } }, + execute: () => Promise.reject(new Error('must not run')), + })) + harness.ctx.on('tools/pre-execute', () => Promise.resolve({ kind: 'ask', reason: 'test policy' })) + harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'reject-once' } }) + + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'write notes' }] }) + + await vi.waitFor(() => { + expect(harness!.updates.some(update => update.sessionUpdate === 'tool_call_update')).toBe(true) + }) + const settled = harness.updates.filter(update => update.sessionUpdate === 'tool_call_update') + // A rejection never reaches `in_progress`; the denial arrives as the result. + expect(settled.map(update => update.status)).toEqual(['failed']) + expect(settled[0]).toMatchObject({ toolCallId: 'call-write' }) + }) +}) diff --git a/packages/dsh-acp/tests/session-load.spec.ts b/packages/dsh-acp/tests/session-load.spec.ts new file mode 100644 index 0000000000..5e390fea3f --- /dev/null +++ b/packages/dsh-acp/tests/session-load.spec.ts @@ -0,0 +1,160 @@ +/** + * Reopening a persisted conversation: `session/load` resumes the stored + * session, replays its history to the client, and brings back the mode it ran + * under — the three things an IDE loses when a reopen silently becomes a new + * session. + * + * The conversation under test is seeded OUTSIDE the bridge and then left to go + * cold, which is what a client restart actually looks like from the agent's + * side: nothing about the session is still in memory when the load arrives. + */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION, RequestError, type SessionConfigOption } from '@agentclientprotocol/sdk' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { + makeBridgeHarness, textResponse, updatesOfKind, waitForUpdates, type BridgeHarness, +} from './harness.ts' + +/** The one mode option, asserted to be a select so its values can be read. */ +function modeOption(configOptions: SessionConfigOption[] | null | undefined): Extract< + SessionConfigOption, { type: 'select' } +> { + const [option, ...rest] = configOptions ?? [] + expect(rest).toEqual([]) + if (option?.type !== 'select') throw new Error(`expected one select option, got ${JSON.stringify(configOptions)}`) + return option +} + +/** The text of every update of one kind, in arrival order. */ +function textsOfKind( + harness: BridgeHarness, kind: 'user_message_chunk' | 'agent_message_chunk', +): string[] { + return updatesOfKind(harness, kind) + .map(update => (update.content.type === 'text' ? update.content.text : '')) +} + +/** + * Seed one persisted conversation and let it go cold. + * @param harness - the fixture whose registry and roster compose the agent. + * @param cwd - the working directory the session is created in. + * @param prompt - the user text the scripted model answers. + * @returns the persisted session id, with no live agent left behind. + */ +async function seedColdSession( + harness: BridgeHarness, cwd: string, prompt: string, +): Promise { + const sessionId = SessionId(randomUUID()) + const presets = harness.ctx.get('agentPresets') + if (presets === undefined) throw new Error('the fixture roster is required to seed a session') + const handle = await harness.ctx.agents.create({ + sessionId, + meta: { cwd, agentPreset: 'alpha' }, + agentOptions: { provider: 'mock', model: 'mock' }, + setup: async (agentCtx) => void await presets.mount(agentCtx, 'alpha'), + }) + handle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: prompt }], + source: { kind: 'user' }, + })) + await handle.agent.whenIdle() + // Disposal is what makes this a COLD load: the agent, its session, and the + // loop state are gone, and only the durable log remains. + await handle.dispose() + return sessionId +} + +describe('session/load', () => { + let harness: BridgeHarness | undefined + let root: string | undefined + + afterEach(async () => { + await harness?.dispose() + harness = undefined + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined + }) + + it('advertises the capability', async () => { + harness = await makeBridgeHarness() + const result = await harness.client.initialize({ + protocolVersion: PROTOCOL_VERSION, clientCapabilities: {}, + }) + + expect(result.agentCapabilities?.loadSession).toBe(true) + }) + + it('resumes a cold session with its history, mode, and context', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-acp-load-')) + harness = await makeBridgeHarness({ + script: [textResponse('stored answer'), textResponse('second answer')], + presets: { default: 'beta' }, + persistenceRoot: root, + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const cwd = process.cwd() + const sessionId = await seedColdSession(harness, cwd, 'stored question') + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() + + const loaded = await harness.client.loadSession({ sessionId, cwd, mcpServers: [] }) + + // The transcript the client renders comes only from replay. + await waitForUpdates(harness, 'agent_message_chunk', 1) + expect(textsOfKind(harness, 'user_message_chunk')).toEqual(['stored question']) + expect(textsOfKind(harness, 'agent_message_chunk')).toEqual(['stored answer']) + // The stored preset wins over the roster default (`beta`), and a session + // whose conversation has started comes back with the mode fixed. + const option = modeOption(loaded.configOptions) + expect(option.currentValue).toBe('alpha') + expect(option.options).toEqual([ + { value: 'alpha', name: 'Alpha mode', description: 'the fixture default' }, + ]) + expect(option.description).toContain('already started') + const resumed = harness.ctx.agents.get(SessionId(sessionId)) + expect(resumed).toBeDefined() + expect(harness.ctx.tools.schemas(resumed!).map(schema => schema.name)).toEqual(['alpha']) + + // Context, not just presentation: the next prompt carries the stored turn. + const response = await harness.client.prompt({ + sessionId, prompt: [{ type: 'text', text: 'follow-up' }], + }) + + expect(response.stopReason).toBe('end_turn') + const request = harness.adapter.requests.at(-1) + const sent = JSON.stringify(request?.messages ?? []) + expect(sent).toContain('stored question') + expect(sent).toContain('stored answer') + }) + + it('refuses a session it has never stored', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-acp-load-')) + harness = await makeBridgeHarness({ persistenceRoot: root }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + + // Refused rather than answered with a blank session: a client that is told + // "no such session" can fall back to session/new and know it did. + await expect(harness.client.loadSession({ + sessionId: randomUUID(), cwd: process.cwd(), mcpServers: [], + })).rejects.toThrow(RequestError) + }) + + it('refuses a session stored for another directory', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-acp-load-')) + harness = await makeBridgeHarness({ + script: [textResponse('stored answer')], + presets: { default: 'alpha' }, + persistenceRoot: root, + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const sessionId = await seedColdSession(harness, process.cwd(), 'stored question') + + await expect(harness.client.loadSession({ + sessionId, cwd: tmpdir(), mcpServers: [], + })).rejects.toThrow(RequestError) + }) +}) diff --git a/packages/dsh-acp/tests/tool-events.spec.ts b/packages/dsh-acp/tests/tool-events.spec.ts new file mode 100644 index 0000000000..596b72603b --- /dev/null +++ b/packages/dsh-acp/tests/tool-events.spec.ts @@ -0,0 +1,242 @@ +/** The presentation data this fork exists to publish: tool cards, plans, text. */ + +import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { CallId, createAssistantMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import { makeBridgeHarness, updatesOfKind, waitForEvents, type BridgeHarness } from './harness.ts' + +/** Surface-eligible events must declare how they enter the ordered surface. */ +const SURFACE = { surfaceOp: 'append' } as const + +/** The provenance every scripted assistant message carries in these tests. */ +const PROVENANCE = { provider: 'mock', model: 'mock' } as const + +describe('ACP session-event publication', () => { + let harness: BridgeHarness | undefined + + afterEach(async () => { + await harness?.dispose() + harness = undefined + }) + + /** Open a session and hand back its live agent, ready to append events to. */ + async function openSession(config?: { reasoning?: boolean }): Promise { + harness = await makeBridgeHarness(config === undefined ? {} : { config }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(SessionId(sessionId)) + if (agent === undefined) throw new Error('missing agent') + agent.session.append('turn/start', { turn: 1 }) + agent.session.append('step/start', { turn: 1, step: 0 }) + return agent + } + + it('publishes a tool call as a pending card and its result as completed', async () => { + const agent = await openSession() + const callId = CallId('call-1') + agent.session.append('tool/call', { + turn: 1, + step: 0, + callId, + name: 'read', + arguments: JSON.stringify({ file_path: '/repo/src/main.ts', offset: 40 }), + }) + agent.session.append('tool/result', { + turn: 1, + step: 0, + message: createToolResultMessage({ + callId, content: [{ type: 'text', text: 'file body' }], isError: false, + }), + }, SURFACE) + await waitForEvents(harness!, 2) + + expect(updatesOfKind(harness!, 'tool_call')[0]).toMatchObject({ + sessionUpdate: 'tool_call', + toolCallId: 'call-1', + title: 'read', + kind: 'read', + status: 'pending', + rawInput: { file_path: '/repo/src/main.ts', offset: 40 }, + locations: [{ path: '/repo/src/main.ts', line: 40 }], + }) + expect(updatesOfKind(harness!, 'tool_call_update')[0]).toMatchObject({ + sessionUpdate: 'tool_call_update', + toolCallId: 'call-1', + status: 'completed', + content: [{ type: 'content', content: { type: 'text', text: 'file body' } }], + }) + }) + + it('maps each harness tool name to its ACP kind', async () => { + const agent = await openSession() + const cases = [ + { name: 'bash', kind: 'execute' }, + { name: 'edit', kind: 'edit' }, + { name: 'grep', kind: 'search' }, + { name: 'web_fetch', kind: 'fetch' }, + { name: 'todo_write', kind: 'other' }, + { name: 'a_tool_nobody_registered', kind: 'other' }, + ] + cases.forEach((entry, index) => { + agent.session.append('tool/call', { + turn: 1, step: 0, callId: CallId(`call-${index}`), name: entry.name, arguments: '{}', + }) + }) + await waitForEvents(harness!, cases.length) + + expect(updatesOfKind(harness!, 'tool_call').map(update => ({ + name: update.title, kind: update.kind, + }))).toEqual(cases) + }) + + it('reports a failed tool call as failed and carries the raw result', async () => { + const agent = await openSession() + const callId = CallId('call-boom') + agent.session.append('tool/call', { + turn: 1, step: 0, callId, name: 'bash', arguments: JSON.stringify({ command: 'false' }), + }) + agent.session.append('tool/result', { + turn: 1, + step: 0, + message: createToolResultMessage({ + callId, content: [{ type: 'text', text: 'exit status 1' }], isError: true, + }), + error: { name: 'ToolError', code: 'EXIT_STATUS' }, + }, SURFACE) + await waitForEvents(harness!, 2) + + const settled = updatesOfKind(harness!, 'tool_call_update')[0] + expect(settled).toMatchObject({ toolCallId: 'call-boom', status: 'failed' }) + expect(settled?.rawOutput).toMatchObject({ content: [{ type: 'tool-result', toolCallId: 'call-boom' }] }) + }) + + it('keeps a tool call whose arguments are not JSON', async () => { + const agent = await openSession() + agent.session.append('tool/call', { + turn: 1, step: 0, callId: CallId('call-partial'), name: 'bash', arguments: '{"command": "ec', + }) + await waitForEvents(harness!, 1) + + expect(updatesOfKind(harness!, 'tool_call')[0]).toMatchObject({ + toolCallId: 'call-partial', kind: 'execute', rawInput: '{"command": "ec', locations: [], + }) + }) + + it('streams deltas and does not repeat them when the message commits', async () => { + const agent = await openSession() + agent.session.append('assistant/chunk', { + turn: 1, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' }, + }) + agent.session.append('assistant/chunk', { + turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'hel' }, + }) + agent.session.append('assistant/chunk', { + turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'lo' }, + }) + agent.session.append('assistant/message', { + turn: 1, + step: 0, + message: createAssistantMessage({ + source: PROVENANCE, + content: [{ type: 'reasoning', text: 'thinking' }, { type: 'text', text: 'hello' }], + }), + }, SURFACE) + await waitForEvents(harness!, 3) + + expect(updatesOfKind(harness!, 'agent_message_chunk').map(update => update.content)) + .toEqual([{ type: 'text', text: 'hel' }, { type: 'text', text: 'lo' }]) + expect(updatesOfKind(harness!, 'agent_thought_chunk').map(update => update.content)) + .toEqual([{ type: 'text', text: 'thinking' }]) + }) + + it('publishes a committed message that never streamed', async () => { + const agent = await openSession() + agent.session.append('assistant/message', { + turn: 1, + step: 0, + message: createAssistantMessage({ source: PROVENANCE, content: [{ type: 'text', text: 'whole answer' }] }), + }, SURFACE) + await waitForEvents(harness!, 1) + + expect(updatesOfKind(harness!, 'agent_message_chunk').map(update => update.content)) + .toEqual([{ type: 'text', text: 'whole answer' }]) + }) + + it('re-publishes committed text for a later step of the same turn', async () => { + const agent = await openSession() + agent.session.append('assistant/chunk', { + turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'streamed' }, + }) + agent.session.append('step/end', { turn: 1, step: 0 }) + agent.session.append('step/start', { turn: 1, step: 1 }) + agent.session.append('assistant/message', { + turn: 1, + step: 1, + message: createAssistantMessage({ source: PROVENANCE, content: [{ type: 'text', text: 'second step' }] }), + }, SURFACE) + await waitForEvents(harness!, 2) + + expect(updatesOfKind(harness!, 'agent_message_chunk').map(update => update.content)) + .toEqual([{ type: 'text', text: 'streamed' }, { type: 'text', text: 'second step' }]) + }) + + it('withholds reasoning when the deployment turns it off', async () => { + const agent = await openSession({ reasoning: false }) + agent.session.append('assistant/chunk', { + turn: 1, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: 'private' }, + }) + agent.session.append('assistant/chunk', { + turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'public' }, + }) + agent.session.append('assistant/message', { + turn: 1, + step: 0, + message: createAssistantMessage({ + source: PROVENANCE, + content: [{ type: 'reasoning', text: 'private' }, { type: 'text', text: 'public' }], + }), + }, SURFACE) + await waitForEvents(harness!, 1) + + expect(updatesOfKind(harness!, 'agent_thought_chunk')).toHaveLength(0) + expect(updatesOfKind(harness!, 'agent_message_chunk').map(update => update.content)) + .toEqual([{ type: 'text', text: 'public' }]) + }) + + it('republishes each todo write as a plan', async () => { + const agent = await openSession() + agent.session.append('todo/write', { + todos: [ + { content: 'read the failing test', status: 'completed' }, + { content: 'fix the parser', status: 'in_progress' }, + { content: 'run the suite', status: 'pending' }, + ], + }) + await waitForEvents(harness!, 1) + + expect(updatesOfKind(harness!, 'plan')[0]).toEqual({ + sessionUpdate: 'plan', + entries: [ + { content: 'read the failing test', priority: 'medium', status: 'completed' }, + { content: 'fix the parser', priority: 'medium', status: 'in_progress' }, + { content: 'run the suite', priority: 'medium', status: 'pending' }, + ], + }) + }) + + it('keeps harness bookkeeping events off the wire', async () => { + const agent = await openSession() + agent.session.append('step/end', { turn: 1, step: 0 }) + agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + agent.session.append('turn/start', { turn: 2 }) + agent.session.append('step/start', { turn: 2, step: 0 }) + agent.session.append('assistant/chunk', { + turn: 2, step: 0, chunk: { type: 'text-delta', index: 0, text: 'next turn' }, + }) + await waitForEvents(harness!, 1) + + expect(harness!.updates.map(update => update.sessionUpdate)).toEqual(['agent_message_chunk']) + }) +}) diff --git a/packages/dsh-acp/tsconfig.build.json b/packages/dsh-acp/tsconfig.build.json new file mode 100644 index 0000000000..5736159eab --- /dev/null +++ b/packages/dsh-acp/tsconfig.build.json @@ -0,0 +1,21 @@ +// Emit configuration for `lib/`. The base config type-checks src, tests, and +// scripts with `noEmit`; this one compiles src alone, because tests and build +// scripts are not part of what ships. +// +// `rewriteRelativeImportExtensions` (inherited) is what makes the `./bridge.ts` +// import style in src/ legal here: TypeScript rewrites those specifiers to +// `./bridge.js` on the way out, so the emitted tree is plain Node ESM with no +// loader hook. +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "src", + "outDir": "lib", + "declaration": true, + "declarationDir": "lib/types", + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"] +} diff --git a/packages/dsh-acp/tsconfig.json b/packages/dsh-acp/tsconfig.json new file mode 100644 index 0000000000..37d4009701 --- /dev/null +++ b/packages/dsh-acp/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2023", "DOM"], + "module": "nodenext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "exactOptionalPropertyTypes": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src", "tests", "scripts"] +} diff --git a/scripts/check-build-prereqs.mjs b/scripts/check-build-prereqs.mjs index 10908401c6..4d5900f075 100644 --- a/scripts/check-build-prereqs.mjs +++ b/scripts/check-build-prereqs.mjs @@ -57,7 +57,19 @@ function runChecks(rootDir) { }); } - // --- Check 3: sherpa-onnx prebuilt libs --- + // --- Check 3: dsh bridge profile (also a bitfun-desktop Tauri resource) --- + // Only the directory has to exist for code generation; what is inside it is + // the bundle's problem, not cargo's. + if (!existsSync(join(rootDir, 'packages', 'dsh-acp', 'dist-profile'))) { + errors.push({ + name: 'dsh bridge profile', + message: + "packages/dsh-acp/dist-profile is missing. The bitfun-desktop Tauri build script references it as a resource, so 'cargo check -p bitfun-desktop' and 'cargo check --workspace' will fail with \"resource path doesn't exist\". Set BITFUN_SKIP_DSH_PROFILE=1 for a placeholder if you do not need the DeepSeek bridge.", + fix: ['pnpm', 'run', 'prepare:dsh-profile'], + }); + } + + // --- Check 4: sherpa-onnx prebuilt libs --- // sherpa-onnx-sys build.rs auto-detects target/sherpa-onnx-prebuilt//lib/ // and returns immediately without downloading. Only warn for the first-build // scenario where no prebuilt cache exists yet. diff --git a/scripts/check-build-prereqs.test.mjs b/scripts/check-build-prereqs.test.mjs index ef12f4a175..9a32913433 100644 --- a/scripts/check-build-prereqs.test.mjs +++ b/scripts/check-build-prereqs.test.mjs @@ -12,7 +12,12 @@ const repoRoot = path.resolve( ); const scriptPath = path.join(repoRoot, 'scripts/check-build-prereqs.mjs'); -function createTestRoot({ nodeModules = false, mobileWebDist = false, sherpaOnnx = null } = {}) { +function createTestRoot({ + nodeModules = false, + mobileWebDist = false, + dshProfile = false, + sherpaOnnx = null, +} = {}) { const root = mkdtempSync(path.join(tmpdir(), 'bitfun-build-prereqs-')); if (nodeModules) { @@ -25,6 +30,12 @@ function createTestRoot({ nodeModules = false, mobileWebDist = false, sherpaOnnx writeFileSync(path.join(distDir, 'index.html'), ''); } + if (dshProfile) { + mkdirSync(path.join(root, 'packages', 'dsh-acp', 'dist-profile'), { + recursive: true, + }); + } + if (sherpaOnnx) { for (const version of sherpaOnnx) { const libDir = path.join( @@ -55,6 +66,8 @@ if (args[0] === 'install') { } else if (args[0] === 'run' && args[1] === 'prepare:mobile-web') { mkdirSync('src/mobile-web/dist', { recursive: true }); writeFileSync('src/mobile-web/dist/index.html', ''); +} else if (args[0] === 'run' && args[1] === 'prepare:dsh-profile') { + mkdirSync('packages/dsh-acp/dist-profile', { recursive: true }); } `, ); @@ -90,6 +103,7 @@ test('passes when all prerequisites are present (including sherpa-onnx prebuilt) const root = createTestRoot({ nodeModules: true, mobileWebDist: true, + dshProfile: true, sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], }); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -104,6 +118,7 @@ test('passes when all prerequisites are present (including sherpa-onnx prebuilt) test('fails when root node_modules is missing', (t) => { const root = createTestRoot({ mobileWebDist: true, + dshProfile: true, sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], }); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -118,6 +133,7 @@ test('fails when root node_modules is missing', (t) => { test('fails when mobile-web dist is missing', (t) => { const root = createTestRoot({ nodeModules: true, + dshProfile: true, sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], }); t.after(() => rmSync(root, { recursive: true, force: true })); @@ -129,8 +145,23 @@ test('fails when mobile-web dist is missing', (t) => { assert.match(result.stderr, /Fix: pnpm run prepare:mobile-web/); }); +test('fails when the dsh bridge profile is missing', (t) => { + const root = createTestRoot({ + nodeModules: true, + mobileWebDist: true, + sherpaOnnx: ['sherpa-onnx-v1.13.4-osx-arm64-static-lib'], + }); + t.after(() => rmSync(root, { recursive: true, force: true })); + + const result = runCheck(root, { sherpaEnv: '' }); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /\[FAIL\] dsh bridge profile/); + assert.match(result.stderr, /Fix: pnpm run prepare:dsh-profile/); +}); + test('warns when sherpa-onnx prebuilt dir does not exist (first build)', (t) => { - const root = createTestRoot({ nodeModules: true, mobileWebDist: true }); + const root = createTestRoot({ nodeModules: true, mobileWebDist: true, dshProfile: true }); t.after(() => rmSync(root, { recursive: true, force: true })); const result = runCheck(root, { sherpaEnv: '' }); @@ -165,6 +196,7 @@ test('--fix runs fix commands, re-verifies, and exits 0 when errors resolved', ( assert.match(result.stdout, /Attempting fixes/); assert.match(result.stdout, /\$ pnpm install/); assert.match(result.stdout, /\$ pnpm run prepare:mobile-web/); + assert.match(result.stdout, /\$ pnpm run prepare:dsh-profile/); assert.match(result.stdout, /Re-checking prerequisites/); assert.match(result.stdout, /All errors resolved/); }); diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 5dd1834eff..5bd73942ea 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -720,6 +720,14 @@ export const acpClosedFeatureProfileRules = [ 'bitfun-agent-tools/acp-bridge', 'dep:futures', 'dep:serde', + // The bundled DeepSeek Harness profile: `dirs` locates $DSH_HOME, + // `semver` compares the build's minimum dsh version against the installed + // one, and `tar` packs the profile for a remote workspace, which receives + // it as one stream over its own transport. All three are client-only — + // the server surface never launches an agent. + 'dep:dirs', + 'dep:semver', + 'dep:tar', 'dep:bitfun-core', ...acpClientCoreFeatures.map((feature) => `bitfun-core/${feature}`), ], diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 6e7272d510..20d699fafd 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -610,6 +610,12 @@ export function runManifestParserSelfTest({ 'bitfun-agent-tools/acp-bridge', 'dep:futures', 'dep:serde', + // The bundled DeepSeek Harness profile the ACP client materializes: + // $DSH_HOME lookup, a minimum-version comparison, and packing the + // profile for a remote host. + 'dep:dirs', + 'dep:semver', + 'dep:tar', 'dep:bitfun-core', 'bitfun-core/agent-runtime', 'bitfun-core/ssh-remote', diff --git a/scripts/frontend-build-all.mjs b/scripts/frontend-build-all.mjs index 4813cb1efe..3fb13feaea 100644 --- a/scripts/frontend-build-all.mjs +++ b/scripts/frontend-build-all.mjs @@ -1,13 +1,13 @@ #!/usr/bin/env node /** - * Runs the two independent frontend build pipelines in parallel: - * - build:web (type-check + vite build + monaco asset verify) - * - prepare:mobile-web (mobile-web install/build with mtime short-circuit) + * Runs the independent pre-bundle build pipelines in parallel: + * - build:web (type-check + vite build + monaco asset verify) + * - prepare:mobile-web (mobile-web install/build with mtime short-circuit) + * - prepare:dsh-profile (the DeepSeek Harness bridge Tauri ships as a resource) * - * Used as the Tauri beforeBuildCommand so the frontend stage costs - * max(build:web, mobile-web) instead of their sum. Either failure fails the - * whole script with a non-zero exit code. + * Used as the Tauri beforeBuildCommand so the stage costs max(…) instead of + * their sum. Any failure fails the whole script with a non-zero exit code. */ import { spawn } from 'node:child_process'; @@ -58,6 +58,10 @@ function runPrefixed(prefix, command, args) { const codes = await Promise.all([ runPrefixed('web', 'pnpm', ['run', 'build:web']), runPrefixed('mobile-web', 'pnpm', ['run', 'prepare:mobile-web']), + // The DeepSeek Harness bridge Tauri ships as a resource. On a cold tree this + // installs its own pinned toolchain (~30s), which still fits inside the two + // above; it is independent of them, and of BitFun's pnpm store. + runPrefixed('dsh-profile', 'pnpm', ['run', 'prepare:dsh-profile']), ]); const failed = codes.some((code) => code !== 0); diff --git a/scripts/prepare-dsh-profile.mjs b/scripts/prepare-dsh-profile.mjs new file mode 100644 index 0000000000..1d9aa13c3a --- /dev/null +++ b/scripts/prepare-dsh-profile.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +/** + * Build the DeepSeek Harness bridge into the profile directory the desktop + * bundle ships (`packages/dsh-acp/dist-profile`). + * + * BitFun launches DeepSeek Harness as `dsh --profile bitfun-acp`, and copies + * that directory into the user's own dsh install on first use — see + * `src/crates/interfaces/acp/src/client/dsh_profile.rs`. The build has to + * happen here rather than in Cargo because it is a TypeScript compile. + * + * `packages/dsh-acp` is deliberately NOT a pnpm workspace member: it pins the + * whole harness `0.1.0-rc.6` train, and making every contributor's + * `pnpm install` fetch that is a poor trade when only the desktop bundle needs + * it. So this installs the package from its own lockfile, here, once — unless + * a developer already linked a local harness checkout, in which case that + * checkout wins and no install runs. + * + * A failure here fails the build. An app that silently ships no bridge looks + * exactly like an app that ships a working one until a user tries to start a + * DeepSeek session, and that is the wrong place to find out. Set + * `BITFUN_SKIP_DSH_PROFILE=1` to opt out deliberately. + */ + +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const PACKAGE_DIR = path.join(ROOT_DIR, 'packages', 'dsh-acp'); +const OUT_DIR = path.join(PACKAGE_DIR, 'dist-profile'); + +/** One installed harness package is enough to tell a populated tree from a bare one. */ +const DEPS_PROBE = path.join(PACKAGE_DIR, 'node_modules', '@deepseek-ai', 'dsh-app-boot'); + +/** Explains an empty dist-profile to whoever finds one in a build tree. */ +const PLACEHOLDER = `This BitFun build ships no DeepSeek Harness bridge. + +BITFUN_SKIP_DSH_PROFILE was set when this build ran, so packages/dsh-acp was +never compiled. BitFun's profile resolver rejects this unstamped directory and +reports that the bridge is missing from the build. + +To include it: run \`pnpm run prepare:dsh-profile\` without that variable. +`; + +/** + * Run a command in the bridge package directory. + * @param command - the executable to run. + * @param args - its arguments. + * @returns the exit status, with a missing status treated as a failure. + */ +function run(command, args) { + const result = spawnSync(command, args, { + cwd: PACKAGE_DIR, + shell: process.platform === 'win32', + stdio: 'inherit', + env: process.env, + }); + return result.status ?? 1; +} + +/** + * Fail the build, saying what to do about it. + * @param message - what went wrong. + * @param status - the exit status to propagate. + */ +function fail(message, status) { + process.stderr.write(`[dsh-profile] ${message}\n`); + process.stderr.write( + '[dsh-profile] set BITFUN_SKIP_DSH_PROFILE=1 to build without the DeepSeek bridge\n', + ); + process.exit(status === 0 ? 1 : status); +} + +if (process.env.BITFUN_SKIP_DSH_PROFILE === '1') { + process.stdout.write('[dsh-profile] skipped (this build ships no DeepSeek bridge)\n'); + mkdirSync(OUT_DIR, { recursive: true }); + writeFileSync(path.join(OUT_DIR, 'NOT-BUILT.md'), PLACEHOLDER); + process.exit(0); +} + +if (!existsSync(DEPS_PROBE)) { + process.stdout.write('[dsh-profile] installing the harness toolchain from package-lock.json\n'); + const installed = run('npm', ['ci', '--no-audit', '--no-fund']); + if (installed !== 0) fail('npm ci failed', installed); +} + +const compiled = run('npm', ['run', 'build']); +if (compiled !== 0) fail('tsc failed', compiled); + +const packaged = run('node', ['scripts/build-profile.mjs']); +if (packaged !== 0) fail('profile packaging failed', packaged); diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 5dcc080387..397d194b41 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -486,10 +486,19 @@ impl DesktopSessionApplication { let scope = self.resolved_scope(request).await; self.ensure_runtime_ownership(&scope)?; let storage_path = self.storage_path(&scope); - self.compatibility - .ensure_session_loaded_from_storage_path(&storage_path, &turn.session_id, false) + // An externally projected Session has no Runtime state to restore, and + // loading one would rewrite its persisted mode to a local fallback. + if !self + .compatibility + .is_externally_projected_session(&storage_path, &turn.session_id) .await - .map_err(desktop_core_session_error)?; + .map_err(desktop_core_session_error)? + { + self.compatibility + .ensure_session_loaded_from_storage_path(&storage_path, &turn.session_id, false) + .await + .map_err(desktop_core_session_error)?; + } if let Some(local_command) = local_command_turn_record_request(turn)? { return self .agent_runtime diff --git a/src/apps/desktop/tauri.conf.json b/src/apps/desktop/tauri.conf.json index 8ec3c4515d..c8a8da4838 100644 --- a/src/apps/desktop/tauri.conf.json +++ b/src/apps/desktop/tauri.conf.json @@ -22,7 +22,8 @@ "resources/worker_host.js": "resources/worker_host.js", "../../../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md", "../../crates/services/services-integrations/assets/models-dev.LICENSE.txt": "third-party/models.dev/LICENSE.txt", - "../../crates/services/services-integrations/assets/models-dev.provenance.json": "third-party/models.dev/provenance.json" + "../../crates/services/services-integrations/assets/models-dev.provenance.json": "third-party/models.dev/provenance.json", + "../../../packages/dsh-acp/dist-profile": "resources/dsh-profile" }, "macOS": { "dmg": { diff --git a/src/apps/desktop/tauri.dev.conf.json b/src/apps/desktop/tauri.dev.conf.json index c7959fbf1d..a0de7b4f99 100644 --- a/src/apps/desktop/tauri.dev.conf.json +++ b/src/apps/desktop/tauri.dev.conf.json @@ -19,6 +19,7 @@ "resources": { "../../mobile-web/dist": "mobile-web/dist", "resources/worker_host.js": "resources/worker_host.js", + "../../../packages/dsh-acp/dist-profile": "resources/dsh-profile", "../../../THIRD_PARTY_NOTICES.md": "THIRD_PARTY_NOTICES.md", "../../crates/services/services-integrations/assets/models-dev.LICENSE.txt": "third-party/models.dev/LICENSE.txt", "../../crates/services/services-integrations/assets/models-dev.provenance.json": "third-party/models.dev/provenance.json" diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index af3332354e..7b52172a3f 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -21,6 +21,7 @@ use bitfun_agent_runtime::sdk::{ AgentSessionUsagePort, AgentSessionUsageRequest, AgentTurnCancellationResult, AgentTurnSettlementPort, AgentTurnSettlementRequest, SessionTranscript, }; +use bitfun_core_types::{SESSION_PROVIDER_ACP, SESSION_PROVIDER_METADATA_KEY}; use bitfun_harness::HarnessRegistry; use bitfun_runtime_ports::{ AgentContextReloadPort, AgentContextReloadRequest, ClockPort, LocalWorkspaceSnapshotPort, @@ -925,6 +926,28 @@ impl CoreAgentRuntimeCompatibility { .await } + /// True when an external agent (ACP), not this Runtime, drives the Session. + /// + /// The Runtime never starts or completes those Turns, so it holds no + /// history branch for them and loading them into the Session manager only + /// rewrites their mode to a local fallback. Their history is projected by + /// the frontend, which is its only writer. + pub async fn is_externally_projected_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { + let metadata = self + .load_persisted_session_metadata(workspace_path, session_id) + .await?; + Ok(metadata + .as_ref() + .and_then(|metadata| metadata.custom_metadata.as_ref()) + .and_then(|custom| custom.get(SESSION_PROVIDER_METADATA_KEY)) + .and_then(serde_json::Value::as_str) + == Some(SESSION_PROVIDER_ACP)) + } + pub async fn update_persisted_session_metadata( &self, workspace_path: &Path, @@ -1246,6 +1269,17 @@ impl CoreAgentRuntimeCompatibility { turn.session_id, permit.session_id ))); } + if self + .is_externally_projected_session(&permit.storage_path, &permit.session_id) + .await? + { + // No Runtime history branch exists to validate against: the external + // agent owns the conversation and the projection is the only writer. + return self + .persistence + .save_dialog_turn(&permit.storage_path, turn) + .await; + } let session = self .coordinator .get_session_manager() @@ -1907,7 +1941,8 @@ mod tests { get_snapshot_manager_for_workspace, latest_persisted_turn_id, runtime_lineage_snapshot, runtime_port_error, validate_latest_turn_fork_scope, validate_persisted_session_id, CoreAgentRuntimeCompatibility, CoreLocalWorkspaceSnapshot, CoreProductAgentRuntime, - CoreProductEventQueueOwner, CoreSessionOperationsPort, + CoreProductEventQueueOwner, CoreSessionOperationsPort, SESSION_PROVIDER_ACP, + SESSION_PROVIDER_METADATA_KEY, }; use crate::agentic::coordination::{ConversationCoordinator, DialogScheduler}; use crate::agentic::events::{EventQueue, EventQueueConfig, EventRouter}; @@ -3101,4 +3136,138 @@ mod tests { assert_eq!(report.tokens.output_tokens, Some(5)); assert_eq!(report.tokens.total_tokens, Some(15)); } + + #[tokio::test] + async fn externally_projected_turns_persist_without_a_runtime_history_branch() { + let workspace = TestWorkspace::new(); + let storage_path = workspace.path().join("sessions"); + std::fs::create_dir_all(&storage_path).expect("session storage"); + let persistence = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let session_manager = Arc::new(SessionManager::new( + Arc::new(SessionContextStore::new()), + persistence.clone(), + SessionManagerConfig { + max_active_sessions: 100, + session_idle_timeout: Duration::from_secs(3600), + auto_save_interval: Duration::from_secs(300), + enable_persistence: false, + prompt_cache_policy: PromptCachePolicy::default(), + }, + )); + let event_queue = Arc::new(EventQueue::new(EventQueueConfig::default())); + let tool_pipeline = Arc::new(ToolPipeline::new( + Arc::new(TokioRwLock::new(ToolRegistry::new())), + Arc::new(ToolStateManager::new(event_queue.clone())), + None, + )); + let execution_engine = Arc::new(ExecutionEngine::new( + Arc::new(RoundExecutor::new( + Arc::new(StreamProcessor::new(event_queue.clone())), + event_queue.clone(), + tool_pipeline.clone(), + )), + event_queue.clone(), + session_manager.clone(), + Arc::new(ContextCompressor::new(CompressionConfig::default())), + ExecutionEngineConfig::default(), + )); + let coordinator = Arc::new(ConversationCoordinator::new( + session_manager.clone(), + execution_engine, + tool_pipeline, + event_queue, + Arc::new(EventRouter::new()), + Arc::new( + crate::runtime_ownership::CoreRuntimeOwnership::embedded_with_facts( + std::env::temp_dir().join(format!( + "bitfun-product-runtime-ownership-test-{}", + uuid::Uuid::new_v4() + )), + "bitfun".to_string(), + "test", + ), + ), + )); + let scheduler = DialogScheduler::new(coordinator.clone(), session_manager.clone()); + let compatibility = CoreAgentRuntimeCompatibility::build(coordinator, scheduler); + + let save_first_turn = |session_id: &'static str| { + let compatibility = compatibility.clone(); + let storage_path = storage_path.clone(); + async move { + let turn = DialogTurnData::new( + format!("{session_id}-turn-0"), + 0, + session_id.to_string(), + UserMessageData { + id: format!("{session_id}-user-0"), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + ); + let permit = compatibility + .begin_persisted_session_mutation(&storage_path, session_id) + .await + .expect("mutation permit"); + compatibility + .save_persisted_dialog_turn(&permit, &turn) + .await + } + }; + + let acp_session_id = "acp_dsh_projected"; + let mut acp_metadata = SessionMetadata::new( + acp_session_id.to_string(), + "Projected".to_string(), + "acp:dsh".to_string(), + "deepseek".to_string(), + ); + let mut custom_metadata = serde_json::Map::new(); + custom_metadata.insert( + SESSION_PROVIDER_METADATA_KEY.to_string(), + serde_json::Value::String(SESSION_PROVIDER_ACP.to_string()), + ); + acp_metadata.custom_metadata = Some(serde_json::Value::Object(custom_metadata)); + persistence + .save_session_metadata(&storage_path, &acp_metadata) + .await + .expect("acp metadata"); + + save_first_turn(acp_session_id) + .await + .expect("a projected first turn has no branch to validate against"); + assert_eq!( + persistence + .load_session_turns(&storage_path, acp_session_id) + .await + .expect("projected turns") + .len(), + 1 + ); + + let runtime_session_id = "runtime-owned"; + persistence + .save_session_metadata( + &storage_path, + &SessionMetadata::new( + runtime_session_id.to_string(), + "Runtime owned".to_string(), + "agentic".to_string(), + "model-a".to_string(), + ), + ) + .await + .expect("runtime metadata"); + + let error = save_first_turn(runtime_session_id) + .await + .expect_err("a Runtime-owned Session still needs its history branch"); + assert!( + matches!(error, BitFunError::OutcomeUnknown(_)), + "unexpected error: {error}" + ); + } } diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index fbd506b487..44e8510716 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -28,7 +28,7 @@ pub use ai::{ pub use errors::{AiErrorDetail, ErrorCategory}; pub use session::{ validate_session_id, SessionAgentRouteOwner, SessionContinuationPolicy, SessionKind, - SessionModelBindingPolicy, + SessionModelBindingPolicy, SESSION_PROVIDER_ACP, SESSION_PROVIDER_METADATA_KEY, }; pub use session_usage::*; pub use speech::*; diff --git a/src/crates/contracts/core-types/src/session.rs b/src/crates/contracts/core-types/src/session.rs index 54fcc354aa..aeb8e8487d 100644 --- a/src/crates/contracts/core-types/src/session.rs +++ b/src/crates/contracts/core-types/src/session.rs @@ -40,6 +40,16 @@ pub enum SessionAgentRouteOwner { External, } +/// Custom-metadata key naming the subsystem that drives a persisted Session. +pub const SESSION_PROVIDER_METADATA_KEY: &str = "provider"; + +/// `provider` value of a Session driven by an external ACP agent. +/// +/// The Runtime neither starts nor completes those Turns — the frontend +/// projection is their only writer — so persistence treats them as projected +/// history instead of a Runtime history branch. +pub const SESSION_PROVIDER_ACP: &str = "acp"; + pub fn validate_session_id(session_id: &str) -> Result<(), String> { if session_id.is_empty() { return Err("session_id cannot be empty".to_string()); diff --git a/src/crates/interfaces/acp/Cargo.toml b/src/crates/interfaces/acp/Cargo.toml index f517b57dde..254beb1715 100644 --- a/src/crates/interfaces/acp/Cargo.toml +++ b/src/crates/interfaces/acp/Cargo.toml @@ -11,6 +11,9 @@ client = [ "bitfun-agent-tools/acp-bridge", "dep:futures", "dep:serde", + "dep:dirs", + "dep:semver", + "dep:tar", "dep:bitfun-core", "bitfun-core/agent-runtime", "bitfun-core/ssh-remote", @@ -55,6 +58,9 @@ tokio-util = { workspace = true, features = ["compat"] } futures = { workspace = true, optional = true } async-trait = { workspace = true } serde = { workspace = true, optional = true } +dirs = { workspace = true, optional = true } +semver = { workspace = true, optional = true } +tar = { workspace = true, optional = true } serde_json = { workspace = true } chrono = { workspace = true } dashmap = { workspace = true } diff --git a/src/crates/interfaces/acp/src/client/builtin_clients.rs b/src/crates/interfaces/acp/src/client/builtin_clients.rs index 3c6f2af332..e01f719db0 100644 --- a/src/crates/interfaces/acp/src/client/builtin_clients.rs +++ b/src/crates/interfaces/acp/src/client/builtin_clients.rs @@ -8,7 +8,6 @@ const LEGACY_CLAUDE_ACP_ARGS: &[&str] = &["--yes", "@zed-industries/claude-code- const CODEX_ACP_PACKAGE: &str = "@agentclientprotocol/codex-acp"; const CODEX_ACP_ARGS: &[&str] = &["--yes", "@agentclientprotocol/codex-acp@latest"]; const LEGACY_CODEX_ACP_ARGS: &[&str] = &["--yes", "@zed-industries/codex-acp@latest"]; -const DSH_ACP_PACKAGE: &str = "@deepseek-ai/dsh-acp-demo@next"; pub(crate) struct BuiltinAcpClientPreset { pub(crate) id: &'static str, @@ -21,8 +20,16 @@ pub(crate) struct BuiltinAcpClientPreset { pub(crate) install_package: Option<&'static str>, pub(crate) adapter_package: Option<&'static str>, pub(crate) adapter_bin: Option<&'static str>, + /// A profile directory BitFun ships and copies into the agent's own home + /// before launching it. `None` — every preset but dsh — means the CLI is + /// self-contained and the command runs as-is. + pub(crate) bundled_profile: Option<&'static str>, } +/// The profile directory BitFun materializes for DeepSeek Harness. See +/// `dsh_profile.rs`: the bridge ships with BitFun, the harness does not. +pub(crate) const DSH_BUNDLED_PROFILE: &str = "bitfun-acp"; + const BUILTIN_ACP_CLIENT_PRESETS: &[BuiltinAcpClientPreset] = &[ BuiltinAcpClientPreset { id: "opencode", @@ -32,19 +39,22 @@ const BUILTIN_ACP_CLIENT_PRESETS: &[BuiltinAcpClientPreset] = &[ install_package: Some("opencode-ai"), adapter_package: None, adapter_bin: None, + bundled_profile: None, }, - // DeepSeek Harness exposes its automation surface as a native ACP server - // through the `dsh-acp-demo` bin. The server reads `./cordis.yml` by - // default; users can override that path in the preset arguments. The ACP - // package follows DSH's developer-preview `next` release channel. + // DeepSeek Harness (dsh) — the harness has no ACP entry point of its own, + // so BitFun ships one as a dsh PROFILE (packages/dsh-acp) and launches it + // through the user's own installation. The model and the API key stay in + // dsh, where the user configured them; BitFun stores neither. Installable + // from npm like codex, hence install_package; native ACP, hence no adapter. BuiltinAcpClientPreset { id: "dsh", - command: "dsh-acp-demo", - args: &[], - tool_command: "dsh-acp-demo", - install_package: Some(DSH_ACP_PACKAGE), + command: "dsh", + args: &["--profile", DSH_BUNDLED_PROFILE], + tool_command: "dsh", + install_package: Some("@deepseek-ai/dsh"), adapter_package: None, adapter_bin: None, + bundled_profile: Some(DSH_BUNDLED_PROFILE), }, // Oh My Pi (omp) — a terminal coding agent that speaks ACP natively via // `omp acp` (no adapter needed, like opencode). User-managed: omp targets @@ -60,6 +70,7 @@ const BUILTIN_ACP_CLIENT_PRESETS: &[BuiltinAcpClientPreset] = &[ install_package: None, adapter_package: None, adapter_bin: None, + bundled_profile: None, }, BuiltinAcpClientPreset { id: "claude-code", @@ -69,6 +80,7 @@ const BUILTIN_ACP_CLIENT_PRESETS: &[BuiltinAcpClientPreset] = &[ install_package: Some("@anthropic-ai/claude-code"), adapter_package: Some(CLAUDE_ACP_PACKAGE), adapter_bin: Some("claude-agent-acp"), + bundled_profile: None, }, BuiltinAcpClientPreset { id: "codex", @@ -78,6 +90,7 @@ const BUILTIN_ACP_CLIENT_PRESETS: &[BuiltinAcpClientPreset] = &[ install_package: Some("@openai/codex"), adapter_package: Some(CODEX_ACP_PACKAGE), adapter_bin: Some("codex-acp"), + bundled_profile: None, }, ]; @@ -167,19 +180,27 @@ mod tests { } #[test] - fn dsh_is_a_native_acp_preset() { - let preset = builtin_acp_client_preset("dsh").expect("DSH preset registered"); - assert_eq!(preset.command, "dsh-acp-demo"); - assert!(preset.args.is_empty()); - assert_eq!(preset.tool_command, "dsh-acp-demo"); - assert_eq!(preset.install_package, Some(DSH_ACP_PACKAGE)); + fn dsh_preset_launches_the_bitfun_profile() { + let preset = builtin_acp_client_preset("dsh").expect("dsh preset registered"); + assert_eq!(preset.command, "dsh"); + assert_eq!(preset.tool_command, "dsh"); + // The launch IS the profile: BitFun ships the bridge, dsh runs it. + assert_eq!(preset.args, &["--profile", DSH_BUNDLED_PROFILE]); + assert_eq!(preset.bundled_profile, Some(DSH_BUNDLED_PROFILE)); + // Native ACP — the bridge is the profile, not a separate adapter. assert!(preset.adapter_package.is_none()); assert!(preset.adapter_bin.is_none()); + // The harness itself is a plain npm global, so the installer applies. + assert_eq!(preset.install_package, Some("@deepseek-ai/dsh")); - let config = default_config_for_builtin_client("dsh").expect("DSH config"); - assert!(config.enabled); - assert_eq!(config.command, "dsh-acp-demo"); - assert!(config.args.is_empty()); + // Every other preset is self-contained: nothing to materialize. + for preset in BUILTIN_ACP_CLIENT_PRESETS.iter().filter(|p| p.id != "dsh") { + assert!( + preset.bundled_profile.is_none(), + "{} needs no profile", + preset.id + ); + } } #[test] diff --git a/src/crates/interfaces/acp/src/client/dsh_profile.rs b/src/crates/interfaces/acp/src/client/dsh_profile.rs new file mode 100644 index 0000000000..fcd23dc5a8 --- /dev/null +++ b/src/crates/interfaces/acp/src/client/dsh_profile.rs @@ -0,0 +1,910 @@ +//! Materialize the bundled DeepSeek Harness bridge into the user's dsh install. +//! +//! BitFun ships a compiled ACP bridge, but it does not ship a harness: `dsh` is +//! a profile launcher, and a profile is just a directory under +//! `$DSH_HOME/profiles/`. So instead of publishing the bridge to a registry, we +//! copy the built profile next to the harness the user already installed and +//! launch it with `dsh --profile bitfun-acp`. Every `@deepseek-ai/dsh-*` row in +//! that profile resolves out of the user's own installation through the flat +//! `profiles/node_modules` fallback the launcher maintains, so nothing here +//! installs a second harness, and the user's model and credentials — which live +//! in dsh, not in BitFun — apply unchanged. +//! +//! The copy is idempotent: the built profile carries a content digest, and a +//! destination already stamped with the same digest is left alone. +//! +//! A remote workspace gets the same treatment over its own transport — see +//! [`ensure_bundled_profile_remote`] — so "works on my machine" and "works on +//! the box I ssh into" are the same feature and not two. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use bitfun_core::service::remote_ssh::SSHConnectionManager; +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use serde::Deserialize; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use super::remote_shell::{remote_user_shell_command, render_remote_env_assignments, shell_escape}; +use super::requirements::probe_executable; + +/// Points directly at a built profile directory, overriding every other +/// candidate. This is how a packaged build that lays resources out unusually — +/// or a developer testing an unbuilt tree — names the source explicitly. +const SOURCE_DIR_ENV: &str = "BITFUN_DSH_PROFILE_DIR"; + +/// dsh's own state directory variable. Respecting it matters: a user who moved +/// `$DSH_HOME` keeps their models and credentials there, and a profile written +/// under `~/.dsh` instead would simply not be found by the launcher. +const DSH_HOME_ENV: &str = "DSH_HOME"; + +/// Basename of the build stamp `scripts/build-profile.mjs` writes. +const STAMP_FILENAME: &str = ".bitfun-bridge.json"; + +/// Directories the build owns end to end, replaced rather than merged. +/// +/// Merging would leave a previous build's emit behind — a `lib/*.js` that no +/// longer exists in the source still resolves, and the profile boots a mix of +/// two versions. Everything outside these three is left untouched, so anything +/// the user added to the profile directory survives an upgrade. +const MANAGED_SUBDIRECTORIES: &[&str] = &["lib", "presets", "node_modules"]; + +/// What `scripts/build-profile.mjs` records about a build. +#[derive(Debug, Deserialize)] +struct BridgeStamp { + /// Digest over every path and its bytes in the built profile. A version + /// string would not do: during development the bridge version stands still + /// while its code changes, and a stale profile would look current. + content: String, + /// The oldest `@deepseek-ai/dsh` this build is known to boot against. + #[serde(rename = "minDshVersion")] + min_dsh_version: String, +} + +/// Copy the bundled profile into the user's dsh install if it is not there yet. +/// +/// Returns the profile directory the launcher will boot. Errors are the ones +/// worth stopping for: no bundled profile to copy, a dsh too old to run it, or +/// a filesystem that refused the write — each of which would otherwise surface +/// as `dsh` complaining about a profile that does not exist, or as a pile of +/// module resolution failures with no hint about the cause. +pub(crate) async fn ensure_bundled_profile(profile: &str) -> BitFunResult { + let (source, stamp) = bundled_build(profile)?; + + // Checked on every launch, not just when copying: the pairing can break + // later by the user downgrading dsh under a profile that is already current. + require_supported_dsh(&stamp.min_dsh_version).await?; + + let destination = dsh_profiles_directory()?.join(profile); + if read_stamp(&destination) + .ok() + .flatten() + .is_some_and(|installed| installed.content == stamp.content) + { + return Ok(destination); + } + + let target = destination.clone(); + tokio::task::spawn_blocking(move || install_profile(&source, &target)) + .await + .map_err(|error| { + BitFunError::service(format!( + "Failed to install the DeepSeek Harness profile: {error}" + )) + })??; + + log::info!( + "Installed the bundled DeepSeek Harness profile at {}", + destination.display() + ); + Ok(destination) +} + +/// The built profile this BitFun ships, and what it says about itself. +fn bundled_build(profile: &str) -> BitFunResult<(PathBuf, BridgeStamp)> { + let source = bundled_profile_source(profile).ok_or_else(|| { + BitFunError::service(format!( + "The bundled DeepSeek Harness profile '{profile}' is missing from this BitFun build. \ + Build it with `npm run build && node scripts/build-profile.mjs` in packages/dsh-acp, \ + or point {SOURCE_DIR_ENV} at a built profile directory." + )) + })?; + let stamp = read_stamp(&source)?.ok_or_else(|| { + BitFunError::service(format!( + "The bundled DeepSeek Harness profile at {} has no {STAMP_FILENAME}; it is not a \ + finished build.", + source.display() + )) + })?; + Ok((source, stamp)) +} + +/// Fail with an actionable message when the installed dsh predates this build. +/// +/// A dsh we cannot find, or whose version we cannot parse, is not treated as a +/// failure here: a missing `dsh` already surfaces as an install prompt in the +/// agent list, and guessing about an unrecognized version string would block a +/// launch that may well work. +async fn require_supported_dsh(minimum: &str) -> BitFunResult<()> { + let Some(installed) = probe_executable("dsh").await.version else { + return Ok(()); + }; + if version_is_supported(&installed, minimum) { + return Ok(()); + } + Err(BitFunError::service(format!( + "DeepSeek Harness {installed} is older than {minimum}, which this BitFun build requires. \ + Update it with `npm install -g @deepseek-ai/dsh`." + ))) +} + +/// The oldest Node the harness can boot on. +/// +/// `@deepseek-ai/dsh-app-boot` imports `util.parseEnv` on its very first line, +/// and Node added that in 20.12. The harness declares no `engines` of its own, +/// so npm installs it happily onto anything and the mismatch only surfaces as a +/// `SyntaxError` from inside the launcher — which is not a sentence anyone can +/// act on. Its toolchain targets Node 22 LTS; this is the floor, not the +/// recommendation. +const MIN_NODE_VERSION: &str = "20.12.0"; + +/// The reported Node version, when it is too old to boot the harness. +/// +/// Anything unparsable is not a verdict and passes: a host with no `node` on +/// the login shell's PATH may still resolve one for `dsh` itself, and a launch +/// that fails now says why on its own. +fn unsupported_node_version(reported: &str) -> Option { + let reported = reported.trim(); + let version = semver::Version::parse(reported.trim_start_matches('v')).ok()?; + let minimum = semver::Version::parse(MIN_NODE_VERSION).ok()?; + (version < minimum).then(|| reported.to_string()) +} + +/// Whether `installed` is new enough for a build that needs `minimum`. +/// +/// Unparsable on either side means "assume yes" — see the caller. +fn version_is_supported(installed: &str, minimum: &str) -> bool { + let Ok(minimum) = semver::Version::parse(minimum.trim_start_matches('v')) else { + return true; + }; + let Ok(installed) = semver::Version::parse(installed.trim().trim_start_matches('v')) else { + return true; + }; + installed >= minimum +} + +/// Locate the built profile that ships with this BitFun. +fn bundled_profile_source(profile: &str) -> Option { + if let Some(configured) = std::env::var_os(SOURCE_DIR_ENV) { + let configured = PathBuf::from(configured); + return configured.is_dir().then_some(configured); + } + + let mut candidates: Vec = Vec::new(); + if let Ok(executable) = std::env::current_exe() { + if let Some(directory) = executable.parent() { + candidates.push(directory.join("resources").join("dsh-profile")); + if let Some(parent) = directory.parent() { + candidates.push( + parent + .join("Resources") + .join("resources") + .join("dsh-profile"), + ); + candidates.push(parent.join("Resources").join("dsh-profile")); + if let Some(name) = executable.file_name().and_then(|name| name.to_str()) { + candidates.push( + parent + .join("lib") + .join(name) + .join("resources") + .join("dsh-profile"), + ); + candidates.push( + parent + .join("share") + .join(name) + .join("resources") + .join("dsh-profile"), + ); + } + } + } + } + // The development tree, where the build writes straight into the package. + candidates.push( + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../../packages/dsh-acp/dist-profile"), + ); + + candidates + .into_iter() + // A profile directory is only the right one if it is the named profile. + .find(|candidate| { + candidate.is_dir() && profile_name_of(candidate) == Some(profile.to_string()) + }) +} + +/// The profile a built directory declares itself to be. +fn profile_name_of(directory: &Path) -> Option { + #[derive(Deserialize)] + struct NamedProfile { + profile: String, + } + let contents = std::fs::read(directory.join(STAMP_FILENAME)).ok()?; + serde_json::from_slice::(&contents) + .ok() + .map(|stamp| stamp.profile) +} + +/// Read a build stamp, distinguishing "not installed" from "unreadable". +fn read_stamp(directory: &Path) -> BitFunResult> { + let path = directory.join(STAMP_FILENAME); + let contents = match std::fs::read(&path) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(BitFunError::service(format!( + "Failed to read {}: {error}", + path.display() + ))) + } + }; + serde_json::from_slice(&contents) + .map(Some) + .map_err(|error| { + BitFunError::service(format!("Failed to parse {}: {error}", path.display())) + }) +} + +/// `$DSH_HOME/profiles`, created if the user has not run dsh yet. +fn dsh_profiles_directory() -> BitFunResult { + let home = match std::env::var_os(DSH_HOME_ENV) { + Some(home) if !home.is_empty() => PathBuf::from(home), + _ => dirs::home_dir() + .ok_or_else(|| { + BitFunError::service( + "Cannot locate the home directory that holds the DeepSeek Harness install" + .to_string(), + ) + })? + .join(".dsh"), + }; + Ok(home.join("profiles")) +} + +/// Write the built profile into `destination`, stamp last. +/// +/// The stamp is copied only after every other file lands, so an interrupted +/// install reads as absent rather than current and is simply redone. +fn install_profile(source: &Path, destination: &Path) -> BitFunResult<()> { + let write_error = |error: std::io::Error| { + BitFunError::service(format!( + "Failed to install the DeepSeek Harness profile into {}: {error}", + destination.display() + )) + }; + + // A half-written stamp is the one file that must never be believed, so drop + // it up front: everything below runs with the destination marked stale. + match std::fs::remove_file(destination.join(STAMP_FILENAME)) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(write_error(error)), + } + std::fs::create_dir_all(destination).map_err(write_error)?; + for managed in MANAGED_SUBDIRECTORIES { + match std::fs::remove_dir_all(destination.join(managed)) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(write_error(error)), + } + } + + copy_tree(source, destination, true).map_err(write_error)?; + std::fs::copy( + source.join(STAMP_FILENAME), + destination.join(STAMP_FILENAME), + ) + .map_err(write_error)?; + Ok(()) +} + +/// Copy `source` over `destination`, overwriting files and keeping extras. +/// +/// `skip_stamp` holds for the top level only — the stamp is the install marker +/// and its own step. +fn copy_tree(source: &Path, destination: &Path, skip_stamp: bool) -> std::io::Result<()> { + std::fs::create_dir_all(destination)?; + for entry in std::fs::read_dir(source)? { + let entry = entry?; + let name = entry.file_name(); + if skip_stamp && name == STAMP_FILENAME { + continue; + } + let target = destination.join(&name); + if entry.file_type()?.is_dir() { + copy_tree(&entry.path(), &target, false)?; + } else { + // Remove first: overwriting in place would follow a symlink the + // user (or a previous vendoring step) left behind. + match std::fs::remove_file(&target) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error), + } + std::fs::copy(entry.path(), &target)?; + } + } + Ok(()) +} + +// --- Remote workspaces ------------------------------------------------------- + +/// Ceiling for the upload, so a stalled channel surfaces as an error instead of +/// a session that silently never starts. +const REMOTE_INSTALL_TIMEOUT: Duration = Duration::from_secs(180); + +/// Put the bundled profile on the far side of a remote workspace. +/// +/// Same contract as [`ensure_bundled_profile`], expressed over a shell instead +/// of a filesystem: a host already carrying this exact build is left alone. The +/// harness itself stays the user's — only the bridge travels, and it travels as +/// one tar stream over the same transport the session will use, which is what +/// makes this work for container connections where SFTP is not available. +/// +/// Unlike the local path, a missing remote `dsh` is fatal here: there is no +/// install prompt in front of a remote workspace, so the only way the user +/// learns about it is this message. +pub(crate) async fn ensure_bundled_profile_remote( + profile: &str, + launcher: &str, + env: &HashMap, + ssh: &SSHConnectionManager, + connection_id: &str, +) -> BitFunResult<()> { + let (source, stamp) = bundled_build(profile)?; + let exports = remote_exports(env); + + let probe_command = + remote_user_shell_command(&remote_probe_script(profile, launcher, &exports)); + let (stdout, stderr, code) = ssh + .execute_command(connection_id, &probe_command) + .await + .map_err(|error| { + BitFunError::service(format!( + "Failed to inspect the DeepSeek Harness install on the remote host: {error}" + )) + })?; + if code != 0 { + return Err(BitFunError::service(format!( + "Failed to inspect the DeepSeek Harness install on the remote host: {}", + remote_failure_summary(&stderr, &stdout) + ))); + } + let probe = parse_remote_probe(&stdout); + + // Presence is `command -v`, the same question the agent list asks. Asking a + // different one here is how the list ends up saying "installed" while the + // launch says "not installed". + if probe.launcher.is_empty() { + return Err(BitFunError::service(format!( + "DeepSeek Harness is not installed on the remote host: `{launcher}` is not on the login \ + shell's PATH. Install it there with `npm install -g @deepseek-ai/dsh`; BitFun ships \ + the bridge, not the harness." + ))); + } + // The harness is a Node program, and it declares no `engines`, so an old + // Node installs it without complaint and then fails to boot it. Say that + // here rather than letting the user read a SyntaxError out of a log. + if let Some(node) = unsupported_node_version(&probe.node) { + return Err(BitFunError::service(format!( + "The remote host runs Node {node}, and DeepSeek Harness needs at least \ + {MIN_NODE_VERSION} to start (its own toolchain targets Node 22 LTS). Install a newer \ + Node on that host, then start the session again." + ))); + } + if !version_is_supported(&probe.version, &stamp.min_dsh_version) { + return Err(BitFunError::service(format!( + "The remote host runs DeepSeek Harness {}, which is older than {}, the version this \ + BitFun build requires. Update it there with `npm install -g @deepseek-ai/dsh`.", + probe.version, stamp.min_dsh_version + ))); + } + if serde_json::from_str::(&probe.stamp) + .is_ok_and(|installed| installed.content == stamp.content) + { + return Ok(()); + } + + let archive = tokio::task::spawn_blocking(move || archive_profile(&source)) + .await + .map_err(|error| { + BitFunError::service(format!( + "Failed to package the DeepSeek Harness profile for upload: {error}" + )) + })??; + + let install_command = remote_user_shell_command(&remote_install_script(profile, &exports)); + let transport = ssh + .open_workspace_stdio(connection_id, &install_command) + .await + .map_err(|error| { + BitFunError::service(format!( + "Failed to install the DeepSeek Harness profile on the remote host: {error}" + )) + })?; + // Every stream stays bound: dropping all three cancels the command, and the + // extraction is only over once `completion` says so. + let (mut stdin, _stdout, mut stderr, _control, completion) = transport.into_parts(); + + let upload = async { + stdin.write_all(&archive).await.map_err(|error| { + BitFunError::service(format!( + "Failed to send the DeepSeek Harness profile to the remote host: {error}" + )) + })?; + // The remote `tar` reads until end of input, so the shutdown is what + // ends the extraction rather than a timeout. + stdin.shutdown().await.map_err(|error| { + BitFunError::service(format!( + "Failed to send the DeepSeek Harness profile to the remote host: {error}" + )) + })?; + Ok::<_, BitFunError>(completion.wait().await) + }; + + let exit = tokio::time::timeout(REMOTE_INSTALL_TIMEOUT, upload) + .await + .map_err(|_| { + BitFunError::service( + "Timed out installing the DeepSeek Harness profile on the remote host".to_string(), + ) + })??; + + if exit.exit_code != Some(0) { + // The launch path sinks remote stderr, so without this the failure would + // reach the user as an unexplained missing profile. + let mut diagnostics = String::new(); + let _ = tokio::time::timeout( + Duration::from_secs(5), + stderr.read_to_string(&mut diagnostics), + ) + .await; + let summary = remote_failure_summary(&diagnostics, ""); + return Err(BitFunError::service(format!( + "Failed to install the DeepSeek Harness profile on the remote host (exit {}){}", + exit.exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + if summary.is_empty() { + String::new() + } else { + format!(": {summary}") + } + ))); + } + + log::info!( + "Installed the bundled DeepSeek Harness profile on the remote host at {}/{profile}", + probe.profiles + ); + Ok(()) +} + +/// What the remote host says about its dsh install and this profile. +#[derive(Debug, Default, PartialEq)] +struct RemoteProbe { + /// `$DSH_HOME/profiles` as the remote shell expands it. Reported for the + /// log line only — the install script resolves it again on its own, so the + /// two never travel through a quoting round trip. + profiles: String, + /// Where the remote login shell resolves the launcher, or empty when it + /// resolves nowhere. This — not the version below — is what "installed" + /// means, because it is what the agent list's own probe asks. + launcher: String, + /// First line of `dsh --version`, on either stream. Best effort: a launcher + /// that answers nothing is still a launcher. + version: String, + /// What `node --version` says on that host, or empty. The harness is a Node + /// program, so this is half of whether it can start at all. + node: String, + /// The installed build stamp, verbatim, or empty when the profile is new. + stamp: String, +} + +/// Environment the workspace configures, applied to the profile shell too. +/// +/// A workspace that points `DSH_HOME` or `PATH` somewhere unusual has to reach +/// the same install the session will boot from, or we would upload the bridge +/// next to a harness nobody runs. +fn remote_exports(env: &HashMap) -> String { + let assignments = render_remote_env_assignments(env); + if assignments.is_empty() { + return String::new(); + } + format!("export {}\n", assignments.join(" ")) +} + +/// One round trip that answers all three questions. +fn remote_probe_script(profile: &str, launcher: &str, exports: &str) -> String { + let profile = shell_escape(profile); + let launcher = shell_escape(launcher); + format!( + "{exports}profiles=\"${{DSH_HOME:-$HOME/.dsh}}/profiles\"\n\ + printf 'profiles=%s\\n' \"$profiles\"\n\ + printf 'launcher=%s\\n' \"$(command -v {launcher} 2>/dev/null | head -n 1 | tr -d '\\r')\"\n\ + printf 'version=%s\\n' \"$({launcher} --version 2>&1 | head -n 1 | tr -d '\\r')\"\n\ + printf 'node=%s\\n' \"$(node --version 2>/dev/null | head -n 1 | tr -d '\\r')\"\n\ + printf 'stamp=%s\\n' \"$(cat \"$profiles\"/{profile}/{STAMP_FILENAME} 2>/dev/null | tr -d '\\n\\r')\"\n" + ) +} + +/// Read the probe's answers, ignoring whatever the login shell printed around +/// them. +fn parse_remote_probe(stdout: &str) -> RemoteProbe { + let mut probe = RemoteProbe::default(); + for line in stdout.lines() { + let line = line.trim(); + if let Some(value) = line.strip_prefix("profiles=") { + probe.profiles = value.to_string(); + } else if let Some(value) = line.strip_prefix("launcher=") { + probe.launcher = value.to_string(); + } else if let Some(value) = line.strip_prefix("version=") { + probe.version = value.to_string(); + } else if let Some(value) = line.strip_prefix("node=") { + probe.node = value.to_string(); + } else if let Some(value) = line.strip_prefix("stamp=") { + probe.stamp = value.to_string(); + } + } + probe +} + +/// Clear the managed directories, then extract what arrives on stdin. +/// +/// The mirror of [`install_profile`]: same directories replaced rather than +/// merged, same everything-else left alone, and the stamp removed up front so +/// an interrupted upload reads as stale. +fn remote_install_script(profile: &str, exports: &str) -> String { + let profile = shell_escape(profile); + let managed = MANAGED_SUBDIRECTORIES + .iter() + .map(|directory| format!("\"$destination\"/{directory}")) + .collect::>() + .join(" "); + format!( + "set -e\n\ + {exports}profiles=\"${{DSH_HOME:-$HOME/.dsh}}/profiles\"\n\ + destination=\"$profiles\"/{profile}\n\ + mkdir -p \"$destination\"\n\ + rm -f \"$destination\"/{STAMP_FILENAME}\n\ + rm -rf {managed}\n\ + exec tar -xf - -C \"$destination\"\n" + ) +} + +/// Pack the built profile, stamp last. +/// +/// Ordering is the whole point: `tar` writes entries in the order they appear, +/// so a transfer that dies halfway leaves a destination without a current stamp +/// and the next launch simply redoes it. +fn archive_profile(source: &Path) -> BitFunResult> { + let pack_error = |error: std::io::Error| { + BitFunError::service(format!( + "Failed to package the DeepSeek Harness profile at {}: {error}", + source.display() + )) + }; + + let mut paths = Vec::new(); + collect_relative_files(source, Path::new(""), &mut paths).map_err(pack_error)?; + // Deterministic order, so an unchanged build produces an identical stream. + paths.sort(); + + let stamp = Path::new(STAMP_FILENAME); + let mut builder = tar::Builder::new(Vec::new()); + for relative in paths.iter().filter(|path| path.as_path() != stamp) { + builder + .append_path_with_name(source.join(relative), relative) + .map_err(pack_error)?; + } + builder + .append_path_with_name(source.join(stamp), stamp) + .map_err(pack_error)?; + builder.into_inner().map_err(pack_error) +} + +/// Every file under `root/relative`, as paths relative to `root`. +fn collect_relative_files( + root: &Path, + relative: &Path, + out: &mut Vec, +) -> std::io::Result<()> { + for entry in std::fs::read_dir(root.join(relative))? { + let entry = entry?; + let child = relative.join(entry.file_name()); + if entry.file_type()?.is_dir() { + collect_relative_files(root, &child, out)?; + } else { + out.push(child); + } + } + Ok(()) +} + +/// The most useful line of a failed remote command, bounded in length. +fn remote_failure_summary(stderr: &str, stdout: &str) -> String { + let text = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + if text.len() <= 400 { + return text.to_string(); + } + let mut cut = 400; + while cut > 0 && !text.is_char_boundary(cut) { + cut -= 1; + } + format!("{}…", &text[..cut]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch_directory(label: &str) -> PathBuf { + let path = + std::env::temp_dir().join(format!("bitfun-dsh-{label}-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path).expect("scratch directory should be created"); + path + } + + fn write_built_profile(root: &Path, content: &str) { + std::fs::create_dir_all(root.join("lib")).expect("lib should be created"); + std::fs::write(root.join("lib/app.js"), content).expect("app should be written"); + std::fs::write(root.join("cordis.patch.yml"), "- insert: []\n").expect("patch"); + std::fs::write( + root.join(STAMP_FILENAME), + format!( + r#"{{"bridge":"0.0.1","content":"{content}","minDshVersion":"0.1.0-rc.6","profile":"bitfun-acp"}}"# + ), + ) + .expect("stamp should be written"); + } + + #[test] + fn installs_the_built_profile_and_replaces_a_previous_build() { + let source = scratch_directory("source"); + let destination = scratch_directory("destination"); + write_built_profile(&source, "first"); + + install_profile(&source, &destination).expect("first install"); + assert_eq!( + std::fs::read_to_string(destination.join("lib/app.js")).expect("app"), + "first" + ); + + // A file the previous build emitted but this one does not must not + // survive into a tree that now claims to be the new build. + std::fs::write(destination.join("lib/stale.js"), "stale").expect("stale file"); + // Anything outside the managed directories is the user's, and stays. + std::fs::write(destination.join("notes.md"), "mine").expect("user file"); + + write_built_profile(&source, "second"); + install_profile(&source, &destination).expect("second install"); + + assert_eq!( + std::fs::read_to_string(destination.join("lib/app.js")).expect("app"), + "second" + ); + assert!(!destination.join("lib/stale.js").exists()); + assert_eq!( + std::fs::read_to_string(destination.join("notes.md")).expect("user file"), + "mine" + ); + + let _ = std::fs::remove_dir_all(&source); + let _ = std::fs::remove_dir_all(&destination); + } + + #[test] + fn reads_the_content_digest_that_decides_whether_to_reinstall() { + let source = scratch_directory("stamp"); + write_built_profile(&source, "digest"); + + let stamp = read_stamp(&source) + .expect("stamp should be readable") + .expect("stamp should be present"); + assert_eq!(stamp.content, "digest"); + assert_eq!(stamp.min_dsh_version, "0.1.0-rc.6"); + assert_eq!(profile_name_of(&source).as_deref(), Some("bitfun-acp")); + + let empty = scratch_directory("stamp-empty"); + assert!(read_stamp(&empty) + .expect("a missing stamp is not an error") + .is_none()); + + let _ = std::fs::remove_dir_all(&source); + let _ = std::fs::remove_dir_all(&empty); + } + + #[test] + fn compares_prerelease_harness_versions() { + assert!(version_is_supported("0.1.0-rc.6", "0.1.0-rc.6")); + assert!(version_is_supported("0.1.0-rc.7", "0.1.0-rc.6")); + assert!(version_is_supported("0.1.0", "0.1.0-rc.6")); + assert!(version_is_supported("1.2.3", "0.1.0-rc.6")); + assert!(!version_is_supported("0.1.0-rc.5", "0.1.0-rc.6")); + assert!(!version_is_supported("0.0.9", "0.1.0-rc.6")); + // A version we cannot read must not block a launch that may work. + assert!(version_is_supported("dsh dev build", "0.1.0-rc.6")); + } + + #[test] + fn source_lookup_honours_the_override_and_checks_the_profile_name() { + let source = scratch_directory("override"); + write_built_profile(&source, "override"); + + temp_env( + SOURCE_DIR_ENV, + Some(source.to_string_lossy().as_ref()), + || { + assert_eq!( + bundled_profile_source("bitfun-acp").as_deref(), + Some(source.as_path()) + ); + }, + ); + // Without the override, a directory that names a different profile is + // not a match — the repo checkout below it may still be. + assert_ne!(profile_name_of(&source).as_deref(), Some("other")); + + let _ = std::fs::remove_dir_all(&source); + } + + #[test] + fn the_remote_probe_answers_every_question_in_one_round_trip() { + let script = remote_probe_script("bitfun-acp", "dsh", ""); + + // $DSH_HOME is resolved on the far side: reading it here would point at + // the developer's own install, not the workspace's. + assert!(script.contains("profiles=\"${DSH_HOME:-$HOME/.dsh}/profiles\"")); + assert!(script.contains("command -v dsh")); + // Both streams: a launcher that prints its version on stderr is still + // installed, and treating that as absent blocks a working host. + assert!(script.contains("dsh --version 2>&1")); + // The harness is a Node program; asking in the same round trip costs + // nothing and is the difference between a sentence and a stack trace. + assert!(script.contains("node --version")); + assert!(script.contains(STAMP_FILENAME)); + + let probe = parse_remote_probe(concat!( + "Welcome to Ubuntu\n", + "profiles=/home/dev/.dsh/profiles\n", + "launcher=/usr/local/bin/dsh\n", + "version=0.1.0-rc.6\n", + "node=v22.19.0\n", + r#"stamp={"content":"abc","minDshVersion":"0.1.0-rc.6"}"#, + "\n", + )); + assert_eq!(probe.profiles, "/home/dev/.dsh/profiles"); + assert_eq!(probe.launcher, "/usr/local/bin/dsh"); + assert_eq!(probe.version, "0.1.0-rc.6"); + assert_eq!(probe.node, "v22.19.0"); + assert_eq!( + probe.stamp, + r#"{"content":"abc","minDshVersion":"0.1.0-rc.6"}"# + ); + + // A host without dsh reports empty rather than nothing at all, so the + // caller can tell "not installed" from "the probe never ran". + let missing = + parse_remote_probe("profiles=/root/.dsh/profiles\nlauncher=\nversion=\nstamp=\n"); + assert_eq!(missing.launcher, ""); + assert_eq!(missing.version, ""); + assert_eq!(missing.stamp, ""); + + // Installed but mute: the version gate lets an unparsable answer + // through, so this host launches instead of being turned away. + let mute = parse_remote_probe("launcher=/usr/local/bin/dsh\nversion=\nstamp=\n"); + assert!(!mute.launcher.is_empty()); + assert!(version_is_supported(&mute.version, "0.1.0-rc.6")); + } + + #[test] + fn a_node_too_old_for_the_harness_is_named_before_the_launch() { + // The version that sent us here: dsh's first import is `util.parseEnv`, + // which this Node does not have, and npm installed it anyway. + assert_eq!( + unsupported_node_version("v18.19.1").as_deref(), + Some("v18.19.1") + ); + assert_eq!( + unsupported_node_version("v20.11.1").as_deref(), + Some("v20.11.1") + ); + + // At or above the floor, and anything we cannot read, launches. A host + // whose login shell has no `node` may still resolve one for `dsh`. + assert_eq!(unsupported_node_version("v20.12.0"), None); + assert_eq!(unsupported_node_version("v24.4.0"), None); + assert_eq!(unsupported_node_version(""), None); + assert_eq!( + unsupported_node_version("bash: node: command not found"), + None + ); + } + + #[test] + fn the_remote_install_replaces_exactly_what_the_local_one_replaces() { + let script = remote_install_script("bitfun-acp", "export DSH_HOME=/opt/dsh\n"); + + assert!(script.starts_with("set -e\n")); + assert!(script.contains("export DSH_HOME=/opt/dsh")); + assert!(script.contains(&format!("rm -f \"$destination\"/{STAMP_FILENAME}"))); + for managed in MANAGED_SUBDIRECTORIES { + assert!(script.contains(&format!("\"$destination\"/{managed}"))); + } + // Nothing outside the managed set is removed — a user's own files in + // the profile directory survive an upgrade here too. + assert!(!script.contains("rm -rf \"$destination\"\n")); + assert!(script.contains("exec tar -xf - -C \"$destination\"")); + } + + #[test] + fn the_uploaded_archive_carries_the_stamp_last() { + let source = scratch_directory("archive"); + write_built_profile(&source, "packed"); + + let archive = archive_profile(&source).expect("profile should pack"); + let entries: Vec = tar::Archive::new(archive.as_slice()) + .entries() + .expect("entries") + .map(|entry| { + entry + .expect("entry") + .path() + .expect("path") + .display() + .to_string() + }) + .collect(); + + assert!(entries.contains(&"lib/app.js".to_string())); + assert!(entries.contains(&"cordis.patch.yml".to_string())); + // Last, so an interrupted upload leaves a profile that reads as stale. + assert_eq!(entries.last().map(String::as_str), Some(STAMP_FILENAME)); + + let _ = std::fs::remove_dir_all(&source); + } + + #[test] + fn remote_exports_only_carry_what_the_workspace_configured() { + assert_eq!(remote_exports(&HashMap::new()), ""); + assert_eq!( + remote_exports(&HashMap::from([( + "DSH_HOME".to_string(), + "/opt/dsh home".to_string() + )])), + "export DSH_HOME='/opt/dsh home'\n" + ); + } + + /// Set an environment variable for the duration of `body`. + /// + /// Tests in one binary share a process, so this restores what it found. + fn temp_env(key: &str, value: Option<&str>, body: impl FnOnce()) { + let previous = std::env::var_os(key); + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + body(); + match previous { + Some(previous) => std::env::set_var(key, previous), + None => std::env::remove_var(key), + } + } +} diff --git a/src/crates/interfaces/acp/src/client/manager.rs b/src/crates/interfaces/acp/src/client/manager.rs index 45452c9448..28641b80b2 100644 --- a/src/crates/interfaces/acp/src/client/manager.rs +++ b/src/crates/interfaces/acp/src/client/manager.rs @@ -36,12 +36,14 @@ use tokio::sync::{oneshot, Mutex, RwLock}; use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use super::builtin_clients::{ - builtin_client_ids, default_config_for_builtin_client, migrate_legacy_builtin_client_configs, + builtin_acp_client_preset, builtin_client_ids, default_config_for_builtin_client, + migrate_legacy_builtin_client_configs, }; use super::config::{ AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, AcpClientRequirementProbe, AcpClientStatus, RemoteAcpClientRequirementSnapshot, }; +use super::dsh_profile::{ensure_bundled_profile, ensure_bundled_profile_remote}; use super::remote_capability_store::RemoteAcpCapabilityStore; use super::remote_session::{preferred_resume_strategies, AcpRemoteSessionStrategy}; use super::remote_shell::{remote_user_shell_command, render_remote_env_assignments, shell_escape}; @@ -625,7 +627,7 @@ impl AcpClientService { .await } }; - let (transport, child) = match transport_result { + let (transport, child, stderr_tail) = match transport_result { Ok(result) => result, Err(error) => { *connection.status.write().await = AcpClientStatus::Failed; @@ -696,9 +698,12 @@ impl AcpClientService { Ok(Err(_)) => { connect_task.abort(); self.cleanup_failed_startup(connection_id).await; - return Err(BitFunError::service(format!( - "ACP client '{}' exited before initialization completed", - client_id + // The agent is gone; whatever it managed to say on the way out + // is all anyone has to go on, so put it in front of the user + // rather than only in the log. + return Err(BitFunError::service(startup_exit_error_message( + client_id, + &stderr_tail.drained_detail().await, ))); } Err(_) => { @@ -1716,14 +1721,29 @@ impl AcpClientService { client_id: &str, connection_id: &str, config: &AcpClientConfig, - ) -> BitFunResult<(ByteStreams, Child)> { + ) -> BitFunResult<( + ByteStreams, + Child, + StderrTail, + )> { + // An agent whose runtime BitFun ships (dsh) needs it in place before the + // command runs, because the command's only job is to boot it. + if let Some(profile) = builtin_acp_client_preset(client_id).and_then(|p| p.bundled_profile) + { + ensure_bundled_profile(profile).await?; + } + let program = resolve_configured_command(&config.command, &config.env); let mut command = bitfun_core::util::process_manager::create_tokio_command(&program); command .args(&config.args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::inherit()); + // Inheriting sent this to a terminal that a packaged app does not + // have, so a local agent that died at startup was as silent as a + // remote one. Read it instead: it goes to the log, and its tail + // goes into the error the user is shown. + .stderr(Stdio::piped()); apply_command_environment(&mut command, Some(&config.env)); configure_process_group(&mut command); @@ -1755,9 +1775,15 @@ impl AcpClientService { } }; + let stderr_tail = match child.stderr.take() { + Some(stderr) => spawn_stderr_reader(client_id, stderr), + None => StderrTail::default(), + }; + Ok(( ByteStreams::new(Box::pin(stdin.compat_write()), Box::pin(stdout.compat())), child, + stderr_tail, )) } @@ -1771,16 +1797,17 @@ impl AcpClientService { ) -> BitFunResult<( ByteStreams, Option, + StderrTail, )> { match remote_connection_id { Some(remote_connection_id) => self .start_remote_transport(client_id, config, workspace_path, remote_connection_id) .await - .map(|transport| (transport, None)), + .map(|(transport, stderr_tail)| (transport, None, stderr_tail)), None => self .start_local_transport(client_id, connection_id, config) .await - .map(|(transport, child)| (transport, Some(child))), + .map(|(transport, child, stderr_tail)| (transport, Some(child), stderr_tail)), } } @@ -1790,7 +1817,10 @@ impl AcpClientService { config: &AcpClientConfig, workspace_path: Option<&str>, remote_connection_id: &str, - ) -> BitFunResult> { + ) -> BitFunResult<( + ByteStreams, + StderrTail, + )> { let command = render_remote_client_command(config, workspace_path)?; let remote_manager = get_remote_workspace_manager().ok_or_else(|| { BitFunError::service("Remote workspace manager is not initialized".to_string()) @@ -1798,6 +1828,19 @@ impl AcpClientService { let ssh_manager = remote_manager.get_ssh_manager().await.ok_or_else(|| { BitFunError::service("SSH manager is not available for remote ACP".to_string()) })?; + // Same reason as `start_local_transport`: the command below only boots + // the runtime BitFun ships, so it has to be on that host first. + if let Some(profile) = builtin_acp_client_preset(client_id).and_then(|p| p.bundled_profile) + { + ensure_bundled_profile_remote( + profile, + config.command.trim(), + &config.env, + &ssh_manager, + remote_connection_id, + ) + .await?; + } let transport = ssh_manager .open_workspace_stdio(remote_connection_id, &command) .await @@ -1807,14 +1850,24 @@ impl AcpClientService { client_id, error )) })?; - let (writer, reader, mut stderr, _control, _completion) = transport.into_parts(); + let (writer, reader, stderr, _control, completion) = transport.into_parts(); + // Whatever the remote agent says on its way out is the only explanation + // a user gets when it dies at startup; sinking it leaves nothing behind + // but an unexplained broken pipe. + let stderr_tail = spawn_stderr_reader(client_id, stderr); + // An agent that dies mid-session leaves the same broken pipe behind as + // one that never started, so record which it was. + let exit_client_id = client_id.to_string(); tokio::spawn(async move { - let mut sink = tokio::io::sink(); - let _ = tokio::io::copy(&mut stderr, &mut sink).await; + let exit = completion.wait().await; + log::info!( + "Remote ACP client exited: id={exit_client_id} exit_code={:?}", + exit.exit_code + ); }); - Ok(ByteStreams::new( - Box::pin(writer.compat_write()), - Box::pin(reader.compat()), + Ok(( + ByteStreams::new(Box::pin(writer.compat_write()), Box::pin(reader.compat())), + stderr_tail, )) } @@ -2508,6 +2561,114 @@ fn protocol_error(error: impl std::fmt::Display) -> BitFunError { BitFunError::service(format!("ACP protocol error: {}", error)) } +/// The last lines an agent wrote to stderr, kept so a startup failure can say +/// why it failed rather than only that it did. +/// +/// An agent that dies before answering `initialize` leaves nothing behind but a +/// closed pipe. Its stderr is the whole account of what went wrong — a missing +/// runtime, a Node too old for it, a config it refused — and a log file is not +/// where the user is looking when the dialog says the session did not start. +#[derive(Clone)] +struct StderrTail { + lines: Arc>>, + /// Turns true once the reader reaches EOF. + drained: tokio::sync::watch::Receiver, +} + +impl Default for StderrTail { + fn default() -> Self { + // Nothing will ever be written to this one, so it starts drained: the + // wait below sees a value that already satisfies it and returns at once. + let (_sender, drained) = tokio::sync::watch::channel(true); + Self { + lines: Arc::default(), + drained, + } + } +} + +/// How many lines to keep, and how many of those to put in an error message. +/// A stack trace is worth keeping whole in the log and worth quoting only in +/// part in a dialog. +const STDERR_TAIL_LINES: usize = 20; +const STDERR_DETAIL_LINES: usize = 8; +/// How long to let stderr finish arriving after the agent's pipes close. +const STDERR_DRAIN_GRACE: Duration = Duration::from_millis(500); + +impl StderrTail { + fn record(&self, line: &str) { + let Ok(mut lines) = self.lines.lock() else { + return; + }; + if lines.len() == STDERR_TAIL_LINES { + lines.pop_front(); + } + lines.push_back(line.to_string()); + } + + /// Wait for the reader to reach EOF, then quote the tail. + /// + /// The pipe closing is what tells the connect task the agent is gone, and + /// the last lines it wrote can still be in flight when that happens — a + /// stack trace quoted without its final line is the one line that mattered. + async fn drained_detail(&self) -> String { + let mut drained = self.drained.clone(); + let _ = + tokio::time::timeout(STDERR_DRAIN_GRACE, drained.wait_for(|drained| *drained)).await; + self.detail() + } + + fn detail(&self) -> String { + let Ok(lines) = self.lines.lock() else { + return String::new(); + }; + let start = lines.len().saturating_sub(STDERR_DETAIL_LINES); + lines + .iter() + .skip(start) + .cloned() + .collect::>() + .join("\n") + } +} + +/// Drain an agent's stderr into the log, keeping the tail for the error message. +fn spawn_stderr_reader(client_id: &str, stderr: R) -> StderrTail +where + R: tokio::io::AsyncRead + Unpin + Send + 'static, +{ + let (drained_tx, drained_rx) = tokio::sync::watch::channel(false); + let tail = StderrTail { + lines: Arc::default(), + drained: drained_rx, + }; + let client_id = client_id.to_string(); + let sink = tail.clone(); + tokio::spawn(async move { + use tokio::io::AsyncBufReadExt; + + let mut lines = tokio::io::BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + let line = line.trim_end(); + if line.trim().is_empty() { + continue; + } + warn!("ACP client stderr: id={client_id} {line}"); + sink.record(line); + } + let _ = drained_tx.send(true); + }); + tail +} + +fn startup_exit_error_message(client_id: &str, stderr_detail: &str) -> String { + let base = format!("ACP client '{client_id}' exited before initialization completed"); + if stderr_detail.trim().is_empty() { + return base; + } + format!("{base}. It said:\n{stderr_detail}") +} + const STARTUP_TIMEOUT_ERROR_PREFIX: &str = "ACP startup timed out:"; fn startup_timeout_error(client_id: &str, phase: &str) -> BitFunError { @@ -2652,6 +2813,50 @@ mod tests { ); } + #[test] + fn a_startup_failure_quotes_what_the_agent_said() { + // Verbatim from a remote host whose Node was too old for the harness. + // Without these lines the message is "it exited", which is the one + // thing the user already knows. + let said = "SyntaxError: The requested module 'node:util' does not provide an export named 'parseEnv'\nNode.js v18.19.1"; + assert_eq!( + startup_exit_error_message("dsh", said), + format!("ACP client 'dsh' exited before initialization completed. It said:\n{said}") + ); + + // An agent that died silently gets the plain sentence, not a dangling + // colon over an empty quote. + assert_eq!( + startup_exit_error_message("dsh", " "), + "ACP client 'dsh' exited before initialization completed" + ); + } + + #[tokio::test] + async fn the_stderr_tail_keeps_the_last_lines_and_waits_for_eof() { + let (mut writer, reader) = tokio::io::duplex(1024); + let tail = spawn_stderr_reader("dsh", reader); + + let mut written = String::new(); + for line in 0..STDERR_TAIL_LINES + 4 { + written.push_str(&format!("line {line}\n")); + } + tokio::io::AsyncWriteExt::write_all(&mut writer, written.as_bytes()) + .await + .unwrap(); + drop(writer); + + // The wait is what makes this deterministic: the reader is still + // draining when a dead agent's pipes close. + let detail = tail.drained_detail().await; + let lines = detail.lines().collect::>(); + assert_eq!(lines.len(), STDERR_DETAIL_LINES); + assert_eq!( + lines.last().copied(), + Some(format!("line {}", STDERR_TAIL_LINES + 3).as_str()) + ); + } + #[test] fn maps_session_config_values_to_acp_protocol_values() { let select = AcpSessionConfigValue::Select { @@ -2728,8 +2933,11 @@ mod tests { resolve_config_for_client(&AcpClientConfigFile::default(), "dsh", Some("remote-host")) .expect("built-in DSH config"); - assert_eq!(resolved.command, "dsh-acp-demo"); - assert!(resolved.args.is_empty()); + assert_eq!(resolved.command, "dsh"); + // A remote workspace resolves the same launch as a local one, and both + // transports materialize the profile first, so the command finds it + // there either way. + assert_eq!(resolved.args, vec!["--profile", "bitfun-acp"]); assert!(resolved.enabled); } } diff --git a/src/crates/interfaces/acp/src/client/mod.rs b/src/crates/interfaces/acp/src/client/mod.rs index 12fce4ead6..5723ab5866 100644 --- a/src/crates/interfaces/acp/src/client/mod.rs +++ b/src/crates/interfaces/acp/src/client/mod.rs @@ -1,5 +1,6 @@ mod builtin_clients; mod config; +mod dsh_profile; mod manager; mod remote_capability_store; mod remote_session; diff --git a/src/crates/interfaces/acp/src/client/session_persistence.rs b/src/crates/interfaces/acp/src/client/session_persistence.rs index 6855be1bdd..41302ffb92 100644 --- a/src/crates/interfaces/acp/src/client/session_persistence.rs +++ b/src/crates/interfaces/acp/src/client/session_persistence.rs @@ -3,13 +3,19 @@ use std::sync::Arc; use bitfun_core::agentic::persistence::PersistenceManager; use bitfun_core::infrastructure::PathManager; -use bitfun_core::service::session::SessionMetadata; +use bitfun_core::service::session::{ + SessionMetadata, SESSION_PROVIDER_ACP, SESSION_PROVIDER_METADATA_KEY, +}; use bitfun_core::util::errors::{BitFunError, BitFunResult}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -pub(super) const CUSTOM_METADATA_PROVIDER_KEY: &str = "provider"; -pub(super) const CUSTOM_METADATA_PROVIDER_VALUE: &str = "acp"; +// Persistence reads these back to tell an externally projected Session from a +// Runtime-owned one, so both sides share one definition. It reaches this crate +// through the Session module it already reads metadata types from, because the +// ACP client owns no direct dependency on the core contracts. +pub(super) const CUSTOM_METADATA_PROVIDER_KEY: &str = SESSION_PROVIDER_METADATA_KEY; +pub(super) const CUSTOM_METADATA_PROVIDER_VALUE: &str = SESSION_PROVIDER_ACP; pub(super) const CUSTOM_METADATA_CLIENT_ID_KEY: &str = "acpClientId"; pub(super) const CUSTOM_METADATA_REMOTE_SESSION_ID_KEY: &str = "acpRemoteSessionId"; pub(super) const CUSTOM_METADATA_RESUME_STRATEGY_KEY: &str = "acpResumeStrategy"; diff --git a/src/crates/interfaces/acp/src/client/stream.rs b/src/crates/interfaces/acp/src/client/stream.rs index 9eee7f98ec..520ed57d94 100644 --- a/src/crates/interfaces/acp/src/client/stream.rs +++ b/src/crates/interfaces/acp/src/client/stream.rs @@ -10,7 +10,7 @@ use bitfun_core::util::errors::{BitFunError, BitFunResult}; use bitfun_events::ToolEventData; use super::session_options::{AcpAvailableCommand, AcpPlanEntry, AcpSessionContextUsage}; -use super::tool_card_bridge::{acp_tool_name, normalize_tool_params}; +use super::tool_card_bridge::{acp_tool_name, normalize_tool_params, normalize_tool_result}; #[derive(Debug, Clone)] pub enum AcpClientStreamEvent { @@ -264,13 +264,15 @@ fn acp_tool_call_events( match tool_call.status { ToolCallStatus::Completed => { + let result = acp_tool_result_value( + &tool_name, + tool_call.raw_output, + Some(tool_call.content), + Some(tool_call.locations), + ); events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { identity: bitfun_events::ToolEventIdentity::direct(tool_id, tool_name), - result: acp_tool_result_value( - tool_call.raw_output, - Some(tool_call.content), - Some(tool_call.locations), - ), + result, result_for_assistant: None, image_attachments: None, duration_ms: 0, @@ -324,13 +326,15 @@ fn acp_tool_call_update_events( timeout_seconds: None, })); } + let result = acp_tool_result_value( + &tool_name, + update.fields.raw_output, + update.fields.content, + update.fields.locations, + ); events.push(AcpClientStreamEvent::ToolEvent(ToolEventData::Completed { identity: bitfun_events::ToolEventIdentity::direct(tool_id, tool_name), - result: acp_tool_result_value( - update.fields.raw_output, - update.fields.content, - update.fields.locations, - ), + result, result_for_assistant: None, image_attachments: None, duration_ms: 0, @@ -400,24 +404,27 @@ fn acp_tool_call_update_events( } fn acp_tool_result_value( + tool_name: &str, raw_output: Option, content: Option>, locations: Option>, ) -> serde_json::Value { - if let Some(raw_output) = raw_output { - return raw_output; - } - - let content = content.unwrap_or_default(); - let locations = locations.unwrap_or_default(); - if content.is_empty() && locations.is_empty() { - return serde_json::Value::Null; - } + let value = match raw_output { + Some(raw_output) => raw_output, + None => { + let content = content.unwrap_or_default(); + let locations = locations.unwrap_or_default(); + if content.is_empty() && locations.is_empty() { + return serde_json::Value::Null; + } + serde_json::json!({ + "content": content, + "locations": locations, + }) + } + }; - serde_json::json!({ - "content": content, - "locations": locations, - }) + normalize_tool_result(tool_name, value) } fn acp_tool_error_text( diff --git a/src/crates/interfaces/acp/src/client/tool_card_bridge/mod.rs b/src/crates/interfaces/acp/src/client/tool_card_bridge/mod.rs index e57901e277..2eb357a450 100644 --- a/src/crates/interfaces/acp/src/client/tool_card_bridge/mod.rs +++ b/src/crates/interfaces/acp/src/client/tool_card_bridge/mod.rs @@ -1,5 +1,6 @@ mod tool_name; mod tool_params; +mod tool_result; pub(super) fn acp_tool_name( title: &str, @@ -16,6 +17,16 @@ pub(super) fn normalize_tool_params( tool_params::normalize_tool_params(tool_name, params) } +/// Translate a completed tool's result into the shape its native card reads. +/// Results the card already understands, and results this cannot parse, are +/// returned unchanged. +pub(super) fn normalize_tool_result( + tool_name: &str, + result: serde_json::Value, +) -> serde_json::Value { + tool_result::normalize_tool_result(tool_name, result) +} + #[cfg(test)] mod tests { use super::{acp_tool_name, normalize_tool_params}; @@ -116,4 +127,156 @@ mod tests { assert_eq!(params["old_string"], "before"); assert_eq!(params["new_string"], "after"); } + + #[test] + fn resolves_the_tool_identity_from_a_title_that_names_the_tool() { + // DeepSeek Harness over ACP puts the tool name in the title and only + // the arguments in rawInput, so the alias table has to reach the title. + let todo_input = json!({ "todos": [{ "content": "ship it", "status": "pending" }] }); + assert_eq!( + acp_tool_name("todo_write", Some(&todo_input), Some(&ToolKind::Other)), + "TodoWrite" + ); + assert_eq!(acp_tool_name("TodoWrite", None, None), "TodoWrite"); + + let read_input = json!({ "file_path": "src/main.rs", "offset": 40 }); + assert_eq!( + acp_tool_name("read", Some(&read_input), Some(&ToolKind::Read)), + "Read" + ); + + let bash_input = json!({ "command": "cargo test" }); + assert_eq!( + acp_tool_name("bash", Some(&bash_input), Some(&ToolKind::Execute)), + "Bash" + ); + + // An unknown harness tool keeps its own name rather than being forced + // into a native card. + assert_eq!( + acp_tool_name( + "subagent", + Some(&json!({ "task": "review" })), + Some(&ToolKind::Other) + ), + "subagent" + ); + } + + #[test] + fn splits_the_str_replace_editor_family_by_its_command() { + // One tool name, several operations. Without the split every call would + // land on Bash, because the `command` key looks like a shell call. + let edit_input = json!({ + "command": "str_replace", + "path": "/repo/src/lib.rs", + "old_str": "before", + "new_str": "after" + }); + assert_eq!( + acp_tool_name( + "str_replace_editor", + Some(&edit_input), + Some(&ToolKind::Edit) + ), + "Edit" + ); + let params = normalize_tool_params("Edit", edit_input); + assert_eq!(params["file_path"], "/repo/src/lib.rs"); + assert_eq!(params["old_string"], "before"); + assert_eq!(params["new_string"], "after"); + + let view_input = json!({ "command": "view", "path": "/repo/README.md" }); + assert_eq!( + acp_tool_name( + "str_replace_editor", + Some(&view_input), + Some(&ToolKind::Read) + ), + "Read" + ); + + let create_input = json!({ + "command": "create", + "path": "/repo/notes.md", + "file_text": "hi" + }); + assert_eq!( + acp_tool_name( + "str_replace_editor", + Some(&create_input), + Some(&ToolKind::Edit) + ), + "Write" + ); + let params = normalize_tool_params("Write", create_input); + assert_eq!(params["file_path"], "/repo/notes.md"); + assert_eq!(params["content"], "hi"); + } + + #[test] + fn code_mode_gets_its_own_card_rather_than_an_empty_terminal() { + // DeepSeek Harness's PTC preset answers every step with one `run_code` + // call whose argument is a TypeScript program. As an Execute kind with + // no `command`, it used to land on the Bash card and render blank. + let input = json!({ + "code": "const files = await tools.bash({ command: \"ls\" });", + "description": "List the project root", + }); + assert_eq!( + acp_tool_name("run_code", Some(&input), Some(&ToolKind::Execute)), + "RunCode" + ); + let params = normalize_tool_params("RunCode", input); + assert_eq!(params["description"], "List the project root"); + + // The shape alone is enough when the tool is named something else. + assert_eq!( + acp_tool_name( + "python", + Some(&json!({ "code": "print(1)" })), + Some(&ToolKind::Execute) + ), + "RunCode" + ); + // …and a harness that spells the program differently still fills the + // field the card reads. + assert_eq!( + normalize_tool_params("RunCode", json!({ "source": "print(1)" }))["code"], + "print(1)" + ); + + // A shell call that happens to carry a `code` argument is still Bash: + // the command is what ran. + assert_eq!( + acp_tool_name( + "bash", + Some(&json!({ "command": "echo hi", "code": "unused" })), + Some(&ToolKind::Execute) + ), + "Bash" + ); + } + + #[test] + fn descriptive_titles_still_reach_the_heuristics() { + // Whole-string aliasing only: a prose title is not a tool name, so the + // rawInput shape keeps deciding these. + assert_eq!( + acp_tool_name( + "Run Bash", + Some(&json!({ "command": "ls" })), + Some(&ToolKind::Execute) + ), + "Bash" + ); + assert_eq!( + acp_tool_name( + "Write to file", + Some(&json!({ "file_path": "a.txt", "content": "hi" })), + Some(&ToolKind::Edit) + ), + "Write" + ); + } } diff --git a/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_name.rs b/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_name.rs index 9549329513..db51570d8f 100644 --- a/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_name.rs +++ b/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_name.rs @@ -6,12 +6,58 @@ pub(super) fn acp_tool_name( kind: Option<&ToolKind>, ) -> String { if let Some(name) = raw_input.and_then(tool_name_from_raw_input) { + if let Some(mapped) = editor_family_tool_name(&name, raw_input) { + return mapped; + } return normalize_tool_name(&name, title, raw_input, kind); } + if let Some(mapped) = editor_family_tool_name(title, raw_input) { + return mapped; + } + + // Agents that carry the tool identity in the title alone get the same alias + // table a rawInput candidate gets. DeepSeek Harness over ACP is one: its + // rawInput is the tool arguments (`{todos}`, `{command}`, …) with no name + // key at all, so `todo_write` would otherwise reach the heuristics below and + // match their "write" substring as a Write card. The match is whole-string, + // so a descriptive title like "Run Bash" still falls through. + let trimmed_title = title.trim(); + let from_title = normalize_known_tool_alias(trimmed_title); + if from_title != trimmed_title || is_native_tool_name(&from_title) { + return from_title; + } + normalize_tool_name("", title, raw_input, kind) } +/// Map the `str_replace_editor` family, whose single tool name multiplexes +/// several file operations behind a `command` argument. +/// +/// The name alone cannot answer which card to draw: every view would open an +/// Edit card. Worse, the generic path never gets that far — the `command` key +/// reads as a shell call and the whole family lands on Bash. DeepSeek Harness's +/// minimal preset ships exactly this tool as one of its two. +fn editor_family_tool_name(name: &str, raw_input: Option<&serde_json::Value>) -> Option { + if !matches!( + name.trim().to_ascii_lowercase().as_str(), + "str_replace_editor" | "str_replace_based_edit_tool" | "text_editor" + ) { + return None; + } + let command = raw_input + .and_then(|value| value.get("command")) + .and_then(|value| value.as_str()) + .unwrap_or_default(); + Some(match command { + "view" => "Read".to_string(), + "create" => "Write".to_string(), + // `str_replace`, `insert`, `undo_edit`, and anything an agent adds + // later: all of them modify a file that already exists. + _ => "Edit".to_string(), + }) +} + fn tool_name_from_raw_input(raw_input: &serde_json::Value) -> Option { let object = raw_input.as_object()?; for key in [ @@ -50,6 +96,17 @@ fn normalize_tool_name( let haystack = format!("{} {}", candidate_lower, title_lower); let input = raw_input.and_then(|value| value.as_object()); if let Some(input) = input { + // A tool that executes and whose whole argument is a `code` blob runs a + // program, not a shell line. DeepSeek Harness's Code Mode (PTC) preset + // is one: it replaces every per-action tool with a single `run_code`. + // Left to the `Execute` fallback below it would draw a terminal card + // whose command is empty, because there is no command. + if matches!(kind, Some(ToolKind::Execute)) + && has_any_key(input, &["code"]) + && !has_any_key(input, &["command", "cmd"]) + { + return "RunCode".to_string(); + } if has_any_key(input, &["command", "cmd"]) { return "Bash".to_string(); } @@ -191,6 +248,7 @@ fn normalize_known_tool_alias(name: &str) -> String { "write" | "write_file" | "create" => "Write".to_string(), "edit" | "patch" | "replace" | "update" => "Edit".to_string(), "delete" | "remove" | "rm" => "Delete".to_string(), + "run_code" | "runcode" | "execute_code" | "code_execute" => "RunCode".to_string(), "todowrite" | "todo_write" | "todo" => "TodoWrite".to_string(), "websearch" | "web_search" | "search_web" => "WebSearch".to_string(), _ => name.to_string(), @@ -208,6 +266,7 @@ fn is_native_tool_name(name: &str) -> bool { | "Grep" | "Glob" | "Bash" + | "RunCode" | "TodoWrite" | "WebSearch" ) diff --git a/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_params.rs b/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_params.rs index 0dcc106e32..02eff11552 100644 --- a/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_params.rs +++ b/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_params.rs @@ -21,6 +21,18 @@ pub(super) fn normalize_tool_params( ); } } + "RunCode" => { + if !normalized.contains_key("code") { + if let Some(value) = normalized + .get("source") + .or_else(|| normalized.get("script")) + .or_else(|| normalized.get("program")) + .cloned() + { + normalized.insert("code".to_string(), value); + } + } + } "Read" | "Write" | "Edit" | "Delete" => { if !normalized.contains_key("file_path") { if let Some(value) = normalized @@ -36,16 +48,35 @@ pub(super) fn normalize_tool_params( } if tool_name == "Edit" { if !normalized.contains_key("old_string") { - if let Some(value) = normalized.get("oldString").cloned() { + // `old_str`/`new_str` are the `str_replace_editor` family's + // spelling; without them its card shows a path and no diff. + if let Some(value) = normalized + .get("oldString") + .or_else(|| normalized.get("old_str")) + .cloned() + { normalized.insert("old_string".to_string(), value); } } if !normalized.contains_key("new_string") { - if let Some(value) = normalized.get("newString").cloned() { + if let Some(value) = normalized + .get("newString") + .or_else(|| normalized.get("new_str")) + .cloned() + { normalized.insert("new_string".to_string(), value); } } } + if tool_name == "Write" && !normalized.contains_key("content") { + if let Some(value) = normalized + .get("file_text") + .or_else(|| normalized.get("contents")) + .cloned() + { + normalized.insert("content".to_string(), value); + } + } } "LS" => { if !normalized.contains_key("path") { diff --git a/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_result.rs b/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_result.rs new file mode 100644 index 0000000000..713dd88e9b --- /dev/null +++ b/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_result.rs @@ -0,0 +1,359 @@ +//! Search results arrive over ACP as the model-facing *text* the harness wrote, +//! while BitFun's Grep/Glob cards read counts and a file list off a structured +//! object. Without a translation the cards render every search as "0 matches / +//! 0 files" even though the search itself succeeded. +//! +//! Parsing belongs here rather than in the cards: the wire format is a property +//! of the agent we speak to, and the cards should keep reading one shape. +//! Anything that does not parse is returned untouched — an unrecognized result +//! is still shown raw, which is better than an invented one. + +use serde_json::{json, Map, Value}; + +/// How deep to walk a result value looking for its text blocks. Real payloads +/// nest three or four levels; the bound only exists so a cyclic-looking shape +/// cannot spin. +const MAX_TEXT_DEPTH: usize = 8; + +pub(super) fn normalize_tool_result(tool_name: &str, result: Value) -> Value { + match tool_name { + "Grep" => normalize_grep_result(result), + "Glob" => normalize_glob_result(result), + "Bash" | "RunCode" => normalize_output_result(result), + _ => result, + } +} + +/// The terminal and run-code cards show `output` (or `stdout`), while an ACP +/// result carries its text in content blocks. Without this the card renders the +/// command it ran and nothing underneath it. +fn normalize_output_result(result: Value) -> Value { + if result.get("output").is_some() || result.get("stdout").is_some() { + return result; + } + let Some(text) = result_text(&result) else { + return result; + }; + + let mut object = match result { + Value::Object(object) => object, + _ => Map::new(), + }; + object.insert("output".to_string(), Value::String(text)); + Value::Object(object) +} + +/// The grep card reads `total_matches` / `file_count`, and shows `result` as the +/// expanded body; `matches` is the same rows in structured form. +fn normalize_grep_result(result: Value) -> Value { + if grep_already_structured(&result) { + return result; + } + let Some(text) = result_text(&result) else { + return result; + }; + let Some(summary) = parse_grep_text(&text) else { + return result; + }; + + let mut object = object_base(result, &text); + object.insert("total_matches".to_string(), json!(summary.total)); + object.insert("file_count".to_string(), json!(summary.file_count)); + object.insert("matches".to_string(), Value::Array(summary.matches)); + Value::Object(object) +} + +/// The glob card reads `files`; the text stays as `result` for the raw view. +fn normalize_glob_result(result: Value) -> Value { + if glob_already_structured(&result) { + return result; + } + let Some(text) = result_text(&result) else { + return result; + }; + let Some(paths) = parse_glob_text(&text) else { + return result; + }; + + let mut object = object_base(result, &text); + object.insert( + "files".to_string(), + Value::Array(paths.into_iter().map(Value::String).collect()), + ); + Value::Object(object) +} + +fn grep_already_structured(result: &Value) -> bool { + result.get("total_matches").is_some() || result.get("matches").is_some_and(Value::is_array) +} + +fn glob_already_structured(result: &Value) -> bool { + result.is_array() + || result.get("files").is_some_and(Value::is_array) + || result.get("matches").is_some_and(Value::is_array) +} + +/// Keep whatever the agent sent and add the card's fields to it, so the raw view +/// still shows the original payload. A non-object result becomes the `result` +/// text the cards already know how to display. +fn object_base(result: Value, text: &str) -> Map { + let mut object = match result { + Value::Object(object) => object, + _ => Map::new(), + }; + object.insert("result".to_string(), Value::String(text.to_string())); + object +} + +/// Collect the text blocks out of a tool result, whichever envelope it came in: +/// a bare string, the ACP `{content: [...]}` fallback, or a harness result +/// message whose `content` nests one more level. +fn result_text(value: &Value) -> Option { + let mut chunks = Vec::new(); + collect_text(value, 0, &mut chunks); + if chunks.is_empty() { + return None; + } + Some(chunks.join("\n")) +} + +fn collect_text(value: &Value, depth: usize, out: &mut Vec) { + if depth > MAX_TEXT_DEPTH { + return; + } + match value { + Value::String(text) => out.push(text.clone()), + Value::Array(items) => { + for item in items { + collect_text(item, depth + 1, out); + } + } + Value::Object(object) => { + if let Some(Value::String(text)) = object.get("text") { + out.push(text.clone()); + } else if let Some(content) = object.get("content") { + collect_text(content, depth + 1, out); + } + } + _ => {} + } +} + +struct GrepSummary { + /// Matches the search found — the header's count, which stays the truth even + /// when the body below it was capped. + total: u64, + /// Distinct files among the rows actually listed. + file_count: usize, + matches: Vec, +} + +/// Parse the harness grep body: a `Found N matches` header, then per file its +/// path followed by one `Line N: ` row per match, with a parenthesized +/// footer when the list was capped. +fn parse_grep_text(text: &str) -> Option { + let trimmed = text.trim(); + if trimmed == "No matches found" { + return Some(GrepSummary { + total: 0, + file_count: 0, + matches: Vec::new(), + }); + } + + let mut lines = trimmed.lines(); + let total = parse_grep_header(lines.next()?)?; + + let mut current_file: Option<&str> = None; + let mut files: Vec<&str> = Vec::new(); + let mut matches = Vec::new(); + for line in lines { + let line = line.trim_end(); + if line.is_empty() { + continue; + } + if let Some((number, matched)) = parse_grep_match_line(line) { + if let Some(file) = current_file { + matches.push(json!({ "file": file, "line": number, "text": matched })); + } + } else if line.starts_with('(') && line.ends_with(')') { + // The truncation / spill footer, not a path. + break; + } else { + current_file = Some(line); + if !files.contains(&line) { + files.push(line); + } + } + } + + Some(GrepSummary { + total, + file_count: files.len(), + matches, + }) +} + +/// `Found 12 matches`, `Found 1 match`, or `Found 30 of 159 matches` — the count +/// reported is always the number the search saw. +fn parse_grep_header(header: &str) -> Option { + let rest = header.trim().strip_prefix("Found ")?; + let counts = rest + .strip_suffix(" matches") + .or_else(|| rest.strip_suffix(" match"))?; + counts.rsplit(" of ").next()?.trim().parse().ok() +} + +fn parse_grep_match_line(line: &str) -> Option<(u64, &str)> { + let (number, matched) = line.strip_prefix("Line ")?.split_once(": ")?; + Some((number.parse().ok()?, matched)) +} + +/// Parse the harness glob body: one path per line, with a parenthesized paging +/// footer when the list was capped. Prose is rejected rather than turned into +/// paths — a path list has no blank or indented lines. +fn parse_glob_text(text: &str) -> Option> { + let trimmed = text.trim(); + if trimmed == "No files found" { + return Some(Vec::new()); + } + + let body = match trimmed.rsplit_once("\n\n") { + Some((head, footer)) if footer.starts_with('(') && footer.ends_with(')') => head, + _ => trimmed, + }; + + let mut paths = Vec::new(); + for line in body.lines() { + let line = line.trim_end(); + if line.is_empty() || line.starts_with(char::is_whitespace) || line.contains('\t') { + return None; + } + paths.push(line.to_string()); + } + if paths.is_empty() { + return None; + } + Some(paths) +} + +#[cfg(test)] +mod tests { + use super::normalize_tool_result; + use serde_json::json; + + /// The envelope DeepSeek Harness sends: a result message whose content + /// nests the text one level deeper than the ACP fallback shape. + fn harness_result(text: &str) -> serde_json::Value { + json!({ + "source": { "kind": "tool", "callId": "call_00" }, + "role": "user", + "content": [{ + "type": "tool-result", + "toolCallId": "call_00", + "isError": false, + "content": [{ "type": "text", "text": text }], + }], + }) + } + + #[test] + fn counts_grep_matches_and_files_from_the_harness_text() { + let result = normalize_tool_result( + "Grep", + harness_result( + "Found 3 matches\n\ndocs/a.md\nLine 12: alpha\nLine 40: beta\n\nsrc/b.rs\nLine 7: gamma", + ), + ); + + assert_eq!(result["total_matches"], 3); + assert_eq!(result["file_count"], 2); + assert_eq!(result["matches"][0]["file"], "docs/a.md"); + assert_eq!(result["matches"][0]["line"], 12); + assert_eq!(result["matches"][0]["text"], "alpha"); + assert_eq!(result["matches"][2]["file"], "src/b.rs"); + // The original text stays available to the expanded view. + assert!(result["result"] + .as_str() + .unwrap() + .starts_with("Found 3 matches")); + assert_eq!(result["source"]["callId"], "call_00"); + } + + #[test] + fn reports_the_searched_total_when_the_listing_was_capped() { + // The body is capped but the search saw 159; showing the capped number + // would understate the result the user asked about. + let result = normalize_tool_result( + "Grep", + json!("Found 2 of 159 matches\n\ndocs/a.md\nLine 1: x\nLine 2: y\n\n(Full grep result stored at: /tmp/grep.txt. Read it.)"), + ); + + assert_eq!(result["total_matches"], 159); + assert_eq!(result["file_count"], 1); + assert_eq!(result["matches"].as_array().unwrap().len(), 2); + } + + #[test] + fn keeps_an_empty_search_empty() { + let grep = normalize_tool_result("Grep", harness_result("No matches found")); + assert_eq!(grep["total_matches"], 0); + assert_eq!(grep["file_count"], 0); + + let glob = normalize_tool_result("Glob", harness_result("No files found")); + assert_eq!(glob["files"].as_array().unwrap().len(), 0); + } + + #[test] + fn lists_glob_paths_and_drops_the_paging_footer() { + let result = normalize_tool_result( + "Glob", + harness_result( + "docs/a.md\ndocs/b.md\n\n(Showing 2 of 40 paths. Narrow path to see more.)", + ), + ); + + assert_eq!(result["files"], json!(["docs/a.md", "docs/b.md"])); + } + + #[test] + fn leaves_a_result_it_cannot_parse_untouched() { + // Prose from some other agent must not become a fake file list. + let prose = harness_result("I looked around the repository.\n\n Nothing matched."); + assert_eq!(normalize_tool_result("Glob", prose.clone()), prose); + assert_eq!(normalize_tool_result("Grep", prose.clone()), prose); + // A tool whose card reads the result as-is is passed straight through. + assert_eq!(normalize_tool_result("Read", prose.clone()), prose); + } + + #[test] + fn lifts_command_and_program_output_to_the_field_those_cards_read() { + let bash = normalize_tool_result("Bash", harness_result("total 0\ndrwxr-xr-x 4 user")); + assert_eq!(bash["output"], "total 0\ndrwxr-xr-x 4 user"); + // The envelope stays intact for the raw view. + assert_eq!(bash["role"], "user"); + + let code = normalize_tool_result("RunCode", harness_result("42")); + assert_eq!(code["output"], "42"); + + // An agent that already speaks the card's shape is left alone. + let native = json!({ "output": "done", "exit_code": 0 }); + assert_eq!(normalize_tool_result("Bash", native.clone()), native); + let piped = json!({ "stdout": "done", "stderr": "" }); + assert_eq!(normalize_tool_result("Bash", piped.clone()), piped); + + // Nothing to lift: a result with no text is untouched rather than + // given an empty output that reads as "the command printed nothing". + let empty = json!({ "locations": [] }); + assert_eq!(normalize_tool_result("RunCode", empty.clone()), empty); + } + + #[test] + fn leaves_an_already_structured_result_alone() { + let grep = json!({ "total_matches": 4, "file_count": 2, "result": "Found 4 matches" }); + assert_eq!(normalize_tool_result("Grep", grep.clone()), grep); + + let glob = json!({ "files": ["a.rs"] }); + assert_eq!(normalize_tool_result("Glob", glob.clone()), glob); + } +} diff --git a/src/crates/services/services-core/src/session/mod.rs b/src/crates/services/services-core/src/session/mod.rs index 31b0445036..7c6fbbf95f 100644 --- a/src/crates/services/services-core/src/session/mod.rs +++ b/src/crates/services/services-core/src/session/mod.rs @@ -9,7 +9,7 @@ pub mod page; pub mod types; mod write_lock; -pub use bitfun_core_types::SessionKind; +pub use bitfun_core_types::{SessionKind, SESSION_PROVIDER_ACP, SESSION_PROVIDER_METADATA_KEY}; pub use layout::SessionStorageLayout; pub use lineage::{ apply_session_lineage, build_branched_session_metadata, build_session_lineage_snapshot, diff --git a/src/crates/services/services-integrations/src/remote_ssh/transport.rs b/src/crates/services/services-integrations/src/remote_ssh/transport.rs index c0729994dd..a3f9d01e07 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/transport.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/transport.rs @@ -394,6 +394,23 @@ impl WorkspacePipes { } } +/// Resolve on the next real signal, and never on the channel closing. +/// +/// A caller that keeps no `WorkspaceProcessControl` — because it drives the +/// process purely over its IO streams — closes this channel the moment the +/// handle it was given falls out of scope. That is not a request to kill +/// anything: what leases the process is the three IO streams, as +/// [`WorkspaceStdio`] documents. Reading the close as a `Kill` killed such a +/// process microseconds after it started, before it could say a word. +async fn next_control_signal( + control_rx: &mut mpsc::Receiver, +) -> WorkspaceProcessSignal { + match control_rx.recv().await { + Some(signal) => signal, + None => std::future::pending().await, + } +} + #[cfg(feature = "remote-ssh-concrete")] async fn run_ssh_channel(mut channel: Channel, mut pipes: WorkspacePipeOwner) { let mut stdin_buffer = vec![0u8; 16 * 1024]; @@ -413,12 +430,12 @@ async fn run_ssh_channel(mut channel: Channel, mut pipes: WorkspacePipeOwne tokio::select! { biased; - signal = pipes.control_rx.recv() => { + signal = next_control_signal(&mut pipes.control_rx) => { match signal { - Some(WorkspaceProcessSignal::Interrupt) => { + WorkspaceProcessSignal::Interrupt => { let _ = channel.signal(Sig::INT).await; } - Some(WorkspaceProcessSignal::Kill) | None => { + WorkspaceProcessSignal::Kill => { let _ = channel.signal(Sig::KILL).await; let _ = channel.close().await; exit_code.get_or_insert(137); @@ -543,10 +560,10 @@ async fn run_local_process( // We are the ones ending the process here, so a signal death says // nothing the caller does not already know. Prefer a status the // child chose for itself and otherwise report the requested intent. - signal = pipes.control_rx.recv() => { + signal = next_control_signal(&mut pipes.control_rx) => { let fallback = match signal { - Some(WorkspaceProcessSignal::Interrupt) => 130, - Some(WorkspaceProcessSignal::Kill) | None => 137, + WorkspaceProcessSignal::Interrupt => 130, + WorkspaceProcessSignal::Kill => 137, }; let _ = child.start_kill(); child.wait().await.ok().and_then(|status| status.code()).or(Some(fallback)) @@ -687,6 +704,18 @@ mod ssh_channel_tests { } async fn workspace_exit_for(report: ExitReport) -> WorkspaceProcessExit { + let transport = workspace_stdio_for(report).await; + let (_stdin, mut stdout, _stderr, _control, completion) = transport.into_parts(); + let mut stdout_bytes = Vec::new(); + let _ = stdout.read_to_end(&mut stdout_bytes).await; + assert_eq!(stdout_bytes, b"workspace output\n"); + + tokio::time::timeout(Duration::from_secs(20), completion.wait()) + .await + .expect("channel owner should report completion") + } + + async fn workspace_stdio_for(report: ExitReport) -> WorkspaceStdio { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("test SSH listener should bind"); @@ -724,15 +753,7 @@ mod ssh_channel_tests { .await .expect("test exec should start"); - let transport = WorkspaceStdio::from_ssh_channel(channel); - let (_stdin, mut stdout, _stderr, _control, completion) = transport.into_parts(); - let mut stdout_bytes = Vec::new(); - let _ = stdout.read_to_end(&mut stdout_bytes).await; - assert_eq!(stdout_bytes, b"workspace output\n"); - - tokio::time::timeout(Duration::from_secs(20), completion.wait()) - .await - .expect("channel owner should report completion") + WorkspaceStdio::from_ssh_channel(channel) } #[tokio::test] @@ -760,6 +781,32 @@ mod ssh_channel_tests { assert_eq!(exit.exit_code, Some(143)); } + #[tokio::test] + async fn dropping_the_control_handle_does_not_end_the_channel() { + let transport = workspace_stdio_for(ExitReport::EofBeforeExitStatus).await; + let (_stdin, mut stdout, _stderr, control, completion) = transport.into_parts(); + // A caller that drives the process over its IO streams alone has no use + // for this handle, and every ACP client is such a caller. Letting it go + // says nothing about whether the process should keep running. + drop(control); + + let mut stdout_bytes = Vec::new(); + let _ = stdout.read_to_end(&mut stdout_bytes).await; + let exit = tokio::time::timeout(Duration::from_secs(20), completion.wait()) + .await + .expect("channel owner should report completion"); + + assert_eq!( + stdout_bytes, b"workspace output\n", + "the remote process must still get to say what it had to say" + ); + assert_eq!( + exit.exit_code, + Some(7), + "a dropped control handle is not a kill request" + ); + } + #[tokio::test] async fn missing_exit_status_stays_unknown() { let exit = workspace_exit_for(ExitReport::NoStatus).await; @@ -883,6 +930,29 @@ mod tests { ); } + #[tokio::test] + #[cfg(unix)] + async fn dropping_the_control_handle_does_not_end_the_local_process() { + let transport = WorkspaceStdio::spawn_local_process( + "sh", + &["-lc".to_string(), "sleep 0.3; exit 5".to_string()], + ) + .unwrap(); + let (_stdin, _stdout, _stderr, control, completion) = transport.into_parts(); + // The IO streams are the lease; this handle is not. + drop(control); + + let exit = tokio::time::timeout(std::time::Duration::from_secs(5), completion.wait()) + .await + .expect("the process should run to its own end"); + + assert_eq!( + exit.exit_code, + Some(5), + "a dropped control handle must not be read as a kill request" + ); + } + #[tokio::test] #[cfg(unix)] async fn dropping_all_io_streams_cancels_local_process() { diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 27881fce2b..ce15126149 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -34,6 +34,7 @@ import { buildAcpFastModeValue, getAcpModelProviderName, resolveAcpFastModeState, + resolveAcpModeState, resolveAcpReasoningState, } from '../utils/acpSessionConfig'; import { sessionProjectWorkspacePath } from '../utils/sessionWorkspace'; @@ -588,6 +589,10 @@ export const ModelSelector: React.FC = ({ () => resolveAcpReasoningState(acpOptions?.configOptions ?? []), [acpOptions?.configOptions], ); + const acpMode = useMemo( + () => resolveAcpModeState(acpOptions?.configOptions ?? []), + [acpOptions?.configOptions], + ); const getCurrentModelId = useCallback((): string => { // Session-owned model takes priority so that each session remembers @@ -963,6 +968,44 @@ export const ModelSelector: React.FC = ({ t, ]); + const handleSelectAcpMode = useCallback(async (value: string) => { + if (loading || !acpMode || !acpClientId || !sessionId) return; + // A locked picker is disabled in the UI; refusing here too keeps a stray + // keyboard activation from asking the agent for something it will refuse. + if (acpMode.locked || acpMode.currentValue === value) return; + + setLoading(true); + try { + const options = await ACPClientAPI.setSessionConfigOption({ + sessionId, + clientId: acpClientId, + workspacePath: activeSession?.workspacePath || activeSession?.config.workspacePath, + remoteConnectionId: activeSession?.remoteConnectionId, + remoteSshHost: activeSession?.remoteSshHost, + configId: acpMode.option.id, + value: { type: 'select', value }, + }); + setAcpOptions(options); + syncAcpContextUsageToStore(sessionId, options); + log.info('ACP session mode updated', { sessionId, acpClientId, value }); + } catch (error) { + log.error('Failed to update ACP session mode', error); + notificationService.error(t('modelSelector.acpModeFailed')); + } finally { + setLoading(false); + } + }, [ + activeSession?.config.workspacePath, + activeSession?.remoteConnectionId, + activeSession?.remoteSshHost, + activeSession?.workspacePath, + acpClientId, + acpMode, + loading, + sessionId, + t, + ]); + const handleTriggerKeyDown = useCallback((event: React.KeyboardEvent) => { if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); @@ -1161,12 +1204,21 @@ export const ModelSelector: React.FC = ({ } if (isAcpSession) { - if (acpAvailableModels.length === 0) { + // An agent may offer models, a mode, or both. dsh-acp offers only a mode, + // so returning early on an empty model list would hide its picker entirely. + if (acpAvailableModels.length === 0 && !acpMode) { return null; } const currentAcpModelId = acpOptions?.currentModelId || acpAvailableModels[0]?.id || ''; - const acpBaseTooltip = getModelTooltipText(acpCurrentModel, acpClientId ? `${acpClientId} ACP` : 'ACP'); + const acpModeLabel = acpMode + ? (acpMode.option.options.find(candidate => candidate.value === acpMode.currentValue)?.name + ?? acpMode.currentValue) + : ''; + const acpModeOnly = acpAvailableModels.length === 0 && acpMode !== null; + const acpBaseTooltip = acpModeOnly + ? (acpMode?.option.description ?? `${acpMode?.option.name ?? ''}: ${acpModeLabel}`) + : getModelTooltipText(acpCurrentModel, acpClientId ? `${acpClientId} ACP` : 'ACP'); const acpTooltip = buildContextUsageTooltip({ baseTooltip: acpBaseTooltip, usage: { @@ -1207,7 +1259,7 @@ export const ModelSelector: React.FC = ({ disabled={loading} data-bf-component="model-selector" data-bf-part="trigger" data-bf-state={dropdownOpen ? 'open' : undefined}> - {getModelDisplayLabel(acpCurrentModel, currentAcpModelId)} + {acpModeOnly ? acpModeLabel : getModelDisplayLabel(acpCurrentModel, currentAcpModelId)} {acpFastMode?.enabled && ( @@ -1248,16 +1300,17 @@ export const ModelSelector: React.FC = ({ aria-hidden={!dropdownOpen} {...(!dropdownOpen ? { inert: '' } : {})} role="menu" - aria-label="ACP model" + aria-label={acpModeOnly ? t('modelSelector.acpMode') : 'ACP model'} onKeyDown={handleDropdownKeyDown} >
- ACP model + {acpModeOnly ? t('modelSelector.acpMode') : 'ACP model'} {acpClientId}
+ {acpAvailableModels.length > 0 && (
{acpAvailableModels.map(model => { const isSelected = currentAcpModelId === model.id; @@ -1294,6 +1347,63 @@ export const ModelSelector: React.FC = ({ ); })}
+ )} + + {acpMode && ( + <> + {acpAvailableModels.length > 0 && ( + <> +
+
+ {t('modelSelector.acpMode')} +
+ + )} +
+ {acpMode.option.options.map(candidate => { + const isSelected = acpMode.currentValue === candidate.value; + // The row stays one line; what a mode does — or why it can no + // longer change — is hover-only. + const hint = acpMode.locked + ? (acpMode.option.description ?? t('modelSelector.acpModeLocked')) + : (candidate.description ?? candidate.name); + + return ( + + + + ); + })} +
+ + )} + {acpFastMode && ( <>
diff --git a/src/web-ui/src/flow_chat/components/ModelSelectorAcpMode.test.tsx b/src/web-ui/src/flow_chat/components/ModelSelectorAcpMode.test.tsx new file mode 100644 index 0000000000..32c43ef664 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ModelSelectorAcpMode.test.tsx @@ -0,0 +1,222 @@ +/** + * @vitest-environment jsdom + * + * The composer's mode picker for ACP sessions. An agent publishes its modes as + * a `mode`-category config option, and dsh-acp publishes ONLY that — no models — + * so this covers the case where the mode is the whole picker, and the case where + * the agent has fixed it and left exactly one choice. + */ + +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ModelSelector } from './ModelSelector'; +import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +const aiApiMocks = vi.hoisted(() => ({ + getModelCatalog: vi.fn(), + onModelCatalogUpdated: vi.fn(), +})); + +const flowChatStoreMocks = vi.hoisted(() => { + type TestSession = { + workspacePath?: string; + config: { agentType?: string; modelName?: string; workspacePath?: string }; + }; + const sessions = new Map(); + const store = { + getState: () => ({ sessions }), + subscribe: vi.fn(() => () => undefined), + updateSessionModelName: vi.fn(), + updateSessionReasoningPreset: vi.fn(), + updateSessionMaxContextTokens: vi.fn(), + updateAcpContextUsage: vi.fn(), + }; + return { sessions, store }; +}); + +vi.mock('@/infrastructure/api/service-api/AIApi', () => ({ aiApi: aiApiMocks })); + +vi.mock('react-i18next', () => ({ + initReactI18next: { type: '3rdParty', init: vi.fn() }, + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@/component-library', () => ({ + // Keep the hover copy inspectable: descriptions live only in the tooltip now. + Tooltip: ({ children, content }: { children: React.ReactNode; content?: React.ReactNode }) => ( + {children} + ), + Switch: () => null, +})); + +vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ + configManager: { + getConfigs: vi.fn(async () => ({ 'ai.models': [] })), + onConfigChange: vi.fn(() => () => undefined), + setConfig: vi.fn(async () => undefined), + }, +})); + +vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ + agentAPI: { updateSessionModel: vi.fn(async () => undefined) }, +})); + +vi.mock('@/infrastructure/api/service-api/ACPClientAPI', () => ({ + ACPClientAPI: { + getSessionOptions: vi.fn(), + setSessionConfigOption: vi.fn(), + onSessionOptionsChanged: vi.fn(() => () => undefined), + }, +})); + +vi.mock('../services/flow-chat-manager/SessionModule', () => ({ + getModelMaxTokens: vi.fn(async () => 128_000), +})); + +vi.mock('@/infrastructure/event-bus', () => ({ + globalEventBus: { emit: vi.fn(), on: vi.fn(), off: vi.fn() }, +})); + +vi.mock('../store/FlowChatStore', () => ({ + FlowChatStore: { getInstance: () => flowChatStoreMocks.store }, +})); + +/** The mode option as an unlocked agent publishes it. */ +const MODE_OPTION = { + id: 'agent-preset', + name: 'Mode', + category: 'mode', + type: 'select' as const, + currentValue: 'standard', + options: [ + { value: 'standard', name: 'Standard', description: 'the default toolset' }, + { value: 'minimal', name: 'Minimal', description: 'bash and an editor' }, + ], +}; + +describe('ModelSelector ACP mode picker', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + flowChatStoreMocks.sessions.clear(); + flowChatStoreMocks.sessions.set('acp-session', { + workspacePath: '/tmp/project', + config: { agentType: 'acp:dsh' }, + }); + const storage = new Map(); + vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, String(value)), + removeItem: (key: string) => storage.delete(key), + clear: () => storage.clear(), + key: (index: number) => [...storage.keys()][index] ?? null, + get length() { return storage.size; }, + }); + aiApiMocks.getModelCatalog.mockResolvedValue({ version: 1, default_models: {}, models: [] }); + aiApiMocks.onModelCatalogUpdated.mockImplementation(() => () => undefined); + class TestResizeObserver { + observe() {} + disconnect() {} + } + vi.stubGlobal('ResizeObserver', TestResizeObserver); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + /** Render the selector against one set of ACP session options. */ + const renderWithOptions = async (configOptions: unknown[]) => { + vi.mocked(ACPClientAPI.getSessionOptions).mockResolvedValue({ + availableModels: [], + configOptions, + } as never); + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { await Promise.resolve(); }); + }; + + it('renders the mode as the whole picker when the agent offers no models', async () => { + await renderWithOptions([MODE_OPTION]); + + const trigger = container.querySelector('[data-testid="chat-model-selector-btn"]'); + // Without this the ACP branch used to return null on an empty model list, + // leaving a dsh session with no picker at all. + expect(trigger, 'the mode picker must render without models').not.toBeNull(); + expect(trigger?.textContent).toContain('Standard'); + + await act(async () => { trigger?.click(); }); + const options = document.body.querySelectorAll('[data-testid="chat-acp-mode-option"]'); + expect([...options].map(option => option.getAttribute('data-mode-value'))) + .toEqual(['standard', 'minimal']); + }); + + it('sends the chosen mode to the agent', async () => { + await renderWithOptions([MODE_OPTION]); + vi.mocked(ACPClientAPI.setSessionConfigOption).mockResolvedValue({ + availableModels: [], + configOptions: [{ ...MODE_OPTION, currentValue: 'minimal' }], + } as never); + + await act(async () => { + container.querySelector('[data-testid="chat-model-selector-btn"]')?.click(); + }); + await act(async () => { + document.body.querySelector( + '[data-testid="chat-acp-mode-option"][data-mode-value="minimal"]', + )?.click(); + await Promise.resolve(); + }); + + expect(ACPClientAPI.setSessionConfigOption).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: 'acp-session', + clientId: 'dsh', + configId: 'agent-preset', + value: { type: 'select', value: 'minimal' }, + })); + expect( + container.querySelector('[data-testid="chat-model-selector-btn"]')?.textContent, + ).toContain('Minimal'); + }); + + it('disables the picker once the agent leaves one mode', async () => { + // How an agent says "fixed" without a flag ACP does not have: it publishes + // only the mode in force, and explains itself in the option description. + await renderWithOptions([{ + ...MODE_OPTION, + description: 'This conversation has already started, so its mode is fixed.', + options: [MODE_OPTION.options[0]], + }]); + + await act(async () => { + container.querySelector('[data-testid="chat-model-selector-btn"]')?.click(); + }); + const options = document.body.querySelectorAll( + '[data-testid="chat-acp-mode-option"]', + ); + expect(options).toHaveLength(1); + expect(options[0]?.disabled).toBe(true); + // The row itself stays a bare mode name; the reason is hover-only. + expect(options[0]?.textContent).toBe('Standard'); + expect(options[0]?.closest('[data-tooltip]')?.getAttribute('data-tooltip')) + .toContain('already started'); + + await act(async () => { + options[0]?.click(); + await Promise.resolve(); + }); + expect(ACPClientAPI.setSessionConfigOption).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts new file mode 100644 index 0000000000..675564a3ca --- /dev/null +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { localSessionDriver } from './LocalSessionDriver'; +import type { DialogTurn } from '../../types/flow-chat'; + +const { mockStartAcpDialogTurn, mockStartAgenticDialogTurn, mockTransition } = vi.hoisted(() => ({ + mockStartAcpDialogTurn: vi.fn(), + mockStartAgenticDialogTurn: vi.fn(), + mockTransition: vi.fn(), +})); + +vi.mock('@/infrastructure/api/service-api/ACPClientAPI', () => ({ + ACPClientAPI: { startDialogTurn: mockStartAcpDialogTurn }, +})); + +vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ + agentAPI: { startDialogTurn: mockStartAgenticDialogTurn }, +})); + +vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ sessionAPI: {} })); +vi.mock('@/infrastructure/api/service-api/WorktreeAPI', () => ({ worktreeAPI: {} })); + +vi.mock('../../state-machine', () => ({ + stateMachineManager: { + transition: mockTransition, + getCurrentState: () => 'idle', + get: () => undefined, + }, +})); + +vi.mock('../shared', () => ({ applyGeneratingTitlePlaceholder: vi.fn() })); + +vi.mock('../../utils/modelSync', () => ({ syncSessionModelSelection: vi.fn() })); + +const SESSION_ID = 'acp_dsh_session'; +const WORKSPACE_PATH = '/Users/user/workspace/project'; + +function persistedTurn(id: string, storageTurnIndex: number): DialogTurn { + return { + id, + sessionId: SESSION_ID, + userMessage: { id: `user-${id}`, content: 'earlier', timestamp: 1 }, + modelRounds: [], + status: 'completed', + startTime: 1, + storageTurnIndex, + }; +} + +function createHarness(existingTurns: DialogTurn[]) { + const session: any = { + sessionId: SESSION_ID, + dialogTurns: [...existingTurns], + workspacePath: WORKSPACE_PATH, + config: {}, + mode: 'acp:dsh', + }; + const addedTurns: DialogTurn[] = []; + + const context: any = { + flowChatStore: { + getState: () => ({ sessions: new Map([[SESSION_ID, session]]) }), + addDialogTurn: (_sessionId: string, turn: DialogTurn) => { + addedTurns.push(turn); + session.dialogTurns = [...session.dialogTurns, turn]; + }, + deleteDialogTurn: vi.fn(), + updateSessionLastSubmittedMode: vi.fn(), + setSessionWorktreeIsolationRequested: vi.fn(), + }, + processingManager: { + registerStatus: vi.fn(), + clearSessionStatus: vi.fn(), + }, + pendingHistoryLoads: new Set(), + contentBuffers: new Map(), + activeTextItems: new Map(), + }; + + return { context, session, addedTurns }; +} + +function startTurnInput(session: any) { + return { + sessionId: SESSION_ID, + message: 'hello', + displayMessage: 'hello', + currentAgentType: 'acp:dsh', + acpClientId: 'dsh', + isFirstMessage: session.dialogTurns.length === 0, + readySession: session, + options: undefined, + } as any; +} + +describe('localSessionDriver.startTurn on an ACP session', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockTransition.mockResolvedValue(true); + mockStartAcpDialogTurn.mockResolvedValue(undefined); + }); + + it('gives the first turn a storage slot so it can be persisted', async () => { + const { context, session, addedTurns } = createHarness([]); + + await localSessionDriver.startTurn(context, startTurnInput(session), { + createdLocalTurnId: null, + }); + + expect(mockStartAcpDialogTurn).toHaveBeenCalledTimes(1); + expect(addedTurns).toHaveLength(1); + expect(addedTurns[0].storageTurnIndex).toBe(0); + }); + + it('continues after the turns already on disk when the session is resumed', async () => { + const { context, session, addedTurns } = createHarness([ + persistedTurn('a', 0), + persistedTurn('b', 1), + ]); + + await localSessionDriver.startTurn(context, startTurnInput(session), { + createdLocalTurnId: null, + }); + + expect(addedTurns[0].storageTurnIndex).toBe(2); + }); + + it('leaves the slot to the runtime for a non-ACP turn', async () => { + const { context, session, addedTurns } = createHarness([]); + mockStartAgenticDialogTurn.mockResolvedValue(undefined); + + await localSessionDriver.startTurn( + context, + { ...startTurnInput(session), acpClientId: undefined, currentAgentType: 'agentic' }, + { createdLocalTurnId: null }, + ); + + expect(mockStartAgenticDialogTurn).toHaveBeenCalledTimes(1); + expect(addedTurns[0].storageTurnIndex).toBeUndefined(); + }); +}); diff --git a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts index 55b89414fb..39894c4cd5 100644 --- a/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts +++ b/src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.ts @@ -31,6 +31,7 @@ import { resolveReasoningPresetForSessionCreation, } from '../../utils/modelResolution'; import { syncSessionModelSelection } from '../../utils/modelSync'; +import { nextStorageTurnIndex } from '../../utils/flowChatTurnIdentity'; import { markCurrentTurnItemsAsCancelled } from '../../utils/turnCancellation'; import { requireSessionProjectWorkspacePath, @@ -297,6 +298,20 @@ export const localSessionDriver: SessionDriver = { `dialog_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const hasImages = (options?.imageContexts?.length ?? 0) > 0; + // An ACP agent runs outside the local runtime, so no backend + // DialogTurnStarted arrives with a storage slot for this Turn. Without one + // every save of the Turn is deferred and the Session never reaches disk, so + // the projection — the only writer of these Turns — allocates it here. + const acpStorageTurnIndex = acpClientId + ? nextStorageTurnIndex(readySession) + : undefined; + if (acpClientId && acpStorageTurnIndex === undefined) { + log.warn('ACP turn starts without a storage slot; its saves stay deferred', { + sessionId, + dialogTurnId, + }); + } + const dialogTurn: DialogTurn = { id: dialogTurnId, sessionId: sessionId, @@ -313,7 +328,8 @@ export const localSessionDriver: SessionDriver = { // Images are attached for multimodal primary models or reduced to text placeholders for text-only models. // We don't run a separate frontend "image pre-analysis" phase here. status: 'pending', - startTime: Date.now() + startTime: Date.now(), + storageTurnIndex: acpStorageTurnIndex, }; context.flowChatStore.addDialogTurn(sessionId, dialogTurn); diff --git a/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.appearance.ts b/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.appearance.ts new file mode 100644 index 0000000000..bd39720970 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.appearance.ts @@ -0,0 +1,13 @@ +import type { AppearanceSurfaceDescriptor } from '@/infrastructure/appearance'; + +export const runCodeToolCardAppearanceDescriptor: AppearanceSurfaceDescriptor = { + id: 'run-code-tool-card', + parts: [ + { id: 'root' }, { id: 'expanded' }, { id: 'section' }, + { id: 'label' }, { id: 'output' }, { id: 'error' }, + ], + states: [ + { id: 'expanded', selector: { kind: 'self', suffix: '[data-bf-state~="expanded"]' } }, + { id: 'failed', selector: { kind: 'self', suffix: '[data-bf-state~="failed"]' } }, + ], +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.scss new file mode 100644 index 0000000000..ddc8725f6d --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.scss @@ -0,0 +1,30 @@ +.run-code-tool-card__expanded { + display: flex; + flex-direction: column; + gap: 12px; +} + +.run-code-tool-card__section { + display: flex; + flex-direction: column; + gap: 6px; +} + +.run-code-tool-card__label { + color: var(--bf-appearance-token-color-text-muted); + font-size: var(--bf-appearance-token-flowchat-font-size-xs); + font-weight: 600; + letter-spacing: 0.01em; + text-transform: uppercase; +} + +.run-code-tool-card__output pre { + margin: 0; +} + +.run-code-tool-card__error { + color: var(--bf-appearance-token-color-error); + font-size: var(--bf-appearance-token-tool-card-action-font-size); + line-height: var(--bf-appearance-token-flowchat-text-line-height); + white-space: pre-wrap; +} diff --git a/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.test.tsx new file mode 100644 index 0000000000..037992e58d --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.test.tsx @@ -0,0 +1,157 @@ +import React from 'react'; +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { JSDOM } from 'jsdom'; + +import { RunCodeToolCard } from './RunCodeToolCard'; +import type { FlowToolItem, ToolCardConfig } from '../types/flow-chat'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock('react-i18next', async () => { + const actual = await vi.importActual('react-i18next'); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, options?: { defaultValue?: string }) => options?.defaultValue ?? key, + }), + }; +}); + +vi.mock('../../component-library', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ToolProcessingDots: () => , + }; +}); + +// Prism loads asynchronously and paints nothing under jsdom; the plain fallback +// is what the assertions below read. +vi.mock('@/shared/utils/syntaxHighlighterLoader', () => ({ + getLoadedPrismSyntaxHighlighter: () => null, + loadPrismSyntaxHighlighter: () => Promise.resolve(null), +})); + +const config: ToolCardConfig = { + toolName: 'RunCode', + displayName: 'Run Code', + icon: 'CODE', + requiresConfirmation: false, + resultDisplayType: 'detailed', + description: 'Run a program and show what it printed', + displayMode: 'standard', +}; + +const PROGRAM = 'const listing = await tools.bash({ command: "ls -la" });\nconsole.log(listing.stdout);'; + +function runCodeItem(overrides: Partial = {}): FlowToolItem { + return { + id: 'tool-run-code-1', + type: 'tool', + toolName: 'RunCode', + status: 'completed', + timestamp: Date.now(), + toolCall: { + id: 'call-run-code-1', + input: { + code: PROGRAM, + description: 'Explore project root structure', + }, + }, + toolResult: { + success: true, + result: { output: 'total 0\ndrwxr-xr-x 4 user staff' }, + }, + ...overrides, + }; +} + +describe('RunCodeToolCard', () => { + let dom: JSDOM; + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + dom = new JSDOM('
', { + pretendToBeVisual: true, + }); + vi.stubGlobal('window', dom.window); + vi.stubGlobal('document', dom.window.document); + vi.stubGlobal('HTMLElement', dom.window.HTMLElement); + vi.stubGlobal('CustomEvent', dom.window.CustomEvent); + vi.stubGlobal('ResizeObserver', class { + observe = vi.fn(); + disconnect = vi.fn(); + }); + + container = dom.window.document.getElementById('root') as HTMLDivElement; + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root.unmount(); + }); + vi.unstubAllGlobals(); + }); + + function render(item: FlowToolItem) { + act(() => { + root.render(); + }); + } + + function expand() { + const card = container.querySelector('.compact-tool-card'); + expect(card).not.toBeNull(); + act(() => { + card?.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })); + }); + } + + it('summarizes the call by what the program was for', () => { + render(runCodeItem()); + + expect(container.textContent).toContain('Explore project root structure'); + }); + + it('falls back to the first real line of the program when there is no description', () => { + render(runCodeItem({ + toolCall: { + id: 'call-run-code-1', + input: { code: `// gather the listing\n\n${PROGRAM}` }, + }, + })); + + expect(container.textContent).toContain('const listing = await tools.bash'); + }); + + it('shows the program and its output once expanded', () => { + render(runCodeItem()); + expand(); + + expect(container.textContent).toContain('console.log(listing.stdout);'); + expect(container.textContent).toContain('drwxr-xr-x 4 user staff'); + }); + + it('reads output out of a result that only carries stdout', () => { + render(runCodeItem({ + toolResult: { success: true, result: { stdout: 'hello from the program' } }, + })); + expand(); + + expect(container.textContent).toContain('hello from the program'); + }); + + it('shows the failure instead of an empty output section', () => { + render(runCodeItem({ + status: 'error', + toolResult: { success: false, error: 'ReferenceError: tools is not defined' }, + })); + expand(); + + expect(container.textContent).toContain('ReferenceError: tools is not defined'); + }); +}); diff --git a/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.tsx b/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.tsx new file mode 100644 index 0000000000..ce22a8c5a0 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.tsx @@ -0,0 +1,178 @@ +/** + * Run Code tool card. + * + * Code-mode agents answer a step by writing one program — DeepSeek Harness's + * PTC preset calls it `run_code` — instead of one tool call per file read or + * shell command. The program is the action, so the card shows it as code and + * whatever it printed underneath, rather than squeezing a TypeScript source + * file into a terminal card's command line. + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { Code2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import type { ToolCardProps } from '../types/flow-chat'; +import { CodePreview } from '../components/CodePreview'; +import { CompactToolCard, CompactToolCardHeader } from './CompactToolCard'; +import { ToolCardStatusSlot } from './ToolCardStatusSlot'; +import { ToolCardCopyAction, ToolCardHeaderActions } from './ToolCardHeaderActions'; +import { useToolCardHeightContract } from './useToolCardHeightContract'; +import { formatSessionViewPreviewText } from '../utils/sessionViewPreview'; +import './RunCodeToolCard.scss'; + +/** Enough of the program to recognize it in a collapsed header. */ +const SUMMARY_CHARS = 80; + +function asText(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +/** + * The first line that says something about what the program does — skipping + * the comment banners and blank lines a model tends to open with. + */ +function firstMeaningfulLine(code: string): string { + for (const line of code.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*')) { + continue; + } + return trimmed.length > SUMMARY_CHARS ? `${trimmed.slice(0, SUMMARY_CHARS)}...` : trimmed; + } + return ''; +} + +function readOutput(result: unknown): string { + if (typeof result === 'string') return formatSessionViewPreviewText(result); + if (!result || typeof result !== 'object') return ''; + + const record = result as Record; + const stdout = asText(record.stdout); + const stderr = asText(record.stderr); + const combined = [stdout, stderr].filter((value) => value.length > 0).join('\n'); + return formatSessionViewPreviewText(asText(record.output) || combined); +} + +export const RunCodeToolCard: React.FC = ({ + toolItem, + config, + onExpand, +}) => { + const { t } = useTranslation('flow-chat'); + const { toolCall, toolResult, status } = toolItem; + const [isExpanded, setIsExpanded] = useState(false); + const toolId = toolItem.id ?? toolCall?.id; + const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ + toolId, + toolName: config.toolName, + }); + + const code = asText(toolCall?.input?.code); + const description = asText(toolCall?.input?.description).trim(); + const output = useMemo(() => readOutput(toolResult?.result), [toolResult?.result]); + const errorMessage = status === 'error' + ? toolResult?.error || t('toolCards.runCode.failed') + : null; + + const canExpand = Boolean(code || output || errorMessage); + const getCopyCodeText = useCallback(() => code, [code]); + + const handleToggleExpand = useCallback(() => { + if (!canExpand) return; + + applyExpandedState(isExpanded, !isExpanded, setIsExpanded, { onExpand }); + }, [applyExpandedState, canExpand, isExpanded, onExpand]); + + const summary = useMemo(() => { + if (errorMessage) return errorMessage; + + const subject = description || firstMeaningfulLine(code); + if (status === 'completed') { + return subject || t('toolCards.runCode.completed'); + } + if (status === 'cancelled') { + return t('toolCards.runCode.cancelled'); + } + if (status === 'rejected') { + return t('toolCards.runCode.rejected'); + } + if (subject) return subject; + + return code ? t('toolCards.runCode.running') : t('toolCards.runCode.writing'); + }, [code, description, errorMessage, status, t]); + + return ( +
+ } />} + action={t('toolCards.runCode.title')} + content={summary} + extra={code ? ( + + + + ) : undefined} + /> + )} + expandedContent={canExpand ? ( +
+ {code && ( +
+
+ {t('toolCards.runCode.programLabel')} +
+ +
+ )} + + {(output || errorMessage) && ( +
+
+ {t('toolCards.runCode.outputLabel')} +
+ {errorMessage ? ( +
{errorMessage}
+ ) : ( +
+
{output}
+
+ )} +
+ )} +
+ ) : undefined} + /> +
+ ); +}; + +export default RunCodeToolCard; diff --git a/src/web-ui/src/flow_chat/tool-cards/index.ts b/src/web-ui/src/flow_chat/tool-cards/index.ts index 0a0d2e8cac..07eef41fab 100644 --- a/src/web-ui/src/flow_chat/tool-cards/index.ts +++ b/src/web-ui/src/flow_chat/tool-cards/index.ts @@ -43,6 +43,7 @@ import { GitToolDisplay } from './GitToolDisplay'; import { GetFileDiffDisplay } from './GetFileDiffDisplay'; import { CreatePlanDisplay } from './CreatePlanDisplay'; import { TerminalToolCard } from './TerminalToolCard'; +import { RunCodeToolCard } from './RunCodeToolCard'; import { ExecCommandToolCard } from './ExecCommandToolCard'; import { WriteStdinToolCard } from './WriteStdinToolCard'; import { ExecControlToolCard } from './ExecControlToolCard'; @@ -113,6 +114,9 @@ export const TOOL_CARD_COMPONENTS = { // Bash tool 'Bash': TerminalToolCard, + // Code-mode agents: one program per step instead of one card per action + 'RunCode': RunCodeToolCard, + // Exec process tools 'ExecCommand': ExecCommandToolCard, 'WriteStdin': WriteStdinToolCard, diff --git a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts index 07dc66da66..e80fe217af 100644 --- a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts +++ b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts @@ -277,6 +277,19 @@ export const TOOL_CARD_CONFIGS: Record = { primaryColor: APPEARANCE_DOMAIN_TOKENS.toolIdentity.terminal }, + // Code-mode agents (e.g. DeepSeek Harness's PTC preset) answer a step by + // writing one program instead of calling one tool per action. + 'RunCode': { + toolName: 'RunCode', + displayName: 'Run Code', + icon: 'CODE', + requiresConfirmation: false, + resultDisplayType: 'detailed', + description: 'Run a program and show what it printed', + displayMode: 'standard', + primaryColor: APPEARANCE_DOMAIN_TOKENS.toolIdentity.terminal + }, + 'ExecCommand': { toolName: 'ExecCommand', displayName: 'Run Command', @@ -531,6 +544,7 @@ export const DEDICATED_TOOL_CARD_NAMES = new Set([ 'SessionControl', 'SessionMessage', 'Bash', + 'RunCode', 'ExecCommand', 'WriteStdin', 'ExecControl', diff --git a/src/web-ui/src/flow_chat/utils/acpSessionConfig.test.ts b/src/web-ui/src/flow_chat/utils/acpSessionConfig.test.ts index debc7dbafd..40cb59360f 100644 --- a/src/web-ui/src/flow_chat/utils/acpSessionConfig.test.ts +++ b/src/web-ui/src/flow_chat/utils/acpSessionConfig.test.ts @@ -4,6 +4,7 @@ import type { AcpSessionConfigOption } from '@/infrastructure/api/service-api/AC import { buildAcpFastModeValue, getAcpModelProviderName, + resolveAcpModeState, resolveAcpReasoningState, resolveAcpFastModeState, } from './acpSessionConfig'; @@ -83,6 +84,50 @@ describe('ACP reasoning config', () => { }); }); +describe('ACP session mode config', () => { + const option: AcpSessionConfigOption = { + id: 'agent-preset', + name: 'Mode', + category: 'mode', + type: 'select', + currentValue: 'standard', + options: [ + { value: 'standard', name: 'Standard' }, + { value: 'minimal', name: 'Minimal' }, + ], + }; + + it('resolves whatever the agent files under the mode category', () => { + expect(resolveAcpModeState([option])).toEqual({ + option, + currentValue: 'standard', + locked: false, + }); + }); + + it('reads one remaining choice as locked', () => { + // The only signal ACP gives an agent for "this can no longer change": + // nothing left to pick. Agents that keep offering alternatives stay live. + const fixed: AcpSessionConfigOption = { + ...option, + description: 'This conversation has already started, so its mode is fixed.', + options: [{ value: 'standard', name: 'Standard' }], + }; + + expect(resolveAcpModeState([fixed])?.locked).toBe(true); + }); + + it('fails closed for an uncategorized, empty, or inconsistent option', () => { + expect(resolveAcpModeState([])).toBeNull(); + expect(resolveAcpModeState([{ ...option, category: undefined }])).toBeNull(); + expect(resolveAcpModeState([{ ...option, options: [] }])).toBeNull(); + expect(resolveAcpModeState([{ ...option, currentValue: 'gone' }])).toBeNull(); + expect(resolveAcpModeState([{ + id: 'agent-preset', name: 'Mode', category: 'mode', type: 'boolean', currentValue: true, + }])).toBeNull(); + }); +}); + describe('ACP model provider display', () => { it('uses the explicit provider name when the ACP bridge includes one', () => { expect(getAcpModelProviderName({ diff --git a/src/web-ui/src/flow_chat/utils/acpSessionConfig.ts b/src/web-ui/src/flow_chat/utils/acpSessionConfig.ts index e8b644bf9f..1b12c5756f 100644 --- a/src/web-ui/src/flow_chat/utils/acpSessionConfig.ts +++ b/src/web-ui/src/flow_chat/utils/acpSessionConfig.ts @@ -14,6 +14,38 @@ export interface AcpFastModeState { enabled: boolean; } +export interface AcpModeState { + option: Extract; + currentValue: string; + /** + * Nothing left to pick. An agent that fixes a session's mode says so by + * publishing only the mode in force — a client cannot know an agent's own + * rule, and one that allows switching mid-conversation keeps its full list. + */ + locked: boolean; +} + +/** + * The session mode selector, if the agent offers one. + * + * ACP's `mode` category is exactly "the picker that is not the model picker", + * so this stays generic: dsh-acp publishes its agent presets here, claude-code + * and codex publish their own permission modes. + */ +export function resolveAcpModeState( + options: AcpSessionConfigOption[], +): AcpModeState | null { + const option = options.find(candidate => candidate.category === 'mode'); + if (!option || option.type !== 'select' || option.options.length === 0) return null; + if (!option.options.some(candidate => candidate.value === option.currentValue)) return null; + + return { + option, + currentValue: option.currentValue, + locked: option.options.length <= 1, + }; +} + export interface AcpReasoningState { option: Extract; projection: ReasoningCatalogProjection; diff --git a/src/web-ui/src/flow_chat/utils/flowChatTurnIdentity.test.ts b/src/web-ui/src/flow_chat/utils/flowChatTurnIdentity.test.ts new file mode 100644 index 0000000000..332154a48a --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/flowChatTurnIdentity.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { nextStorageTurnIndex } from './flowChatTurnIdentity'; +import type { DialogTurn } from '../types/flow-chat'; + +const SESSION_ID = 'acp_dsh_session'; + +function turn(id: string, storageTurnIndex?: number): DialogTurn { + return { + id, + sessionId: SESSION_ID, + userMessage: { id: `user-${id}`, content: 'hi', timestamp: 1 }, + modelRounds: [], + status: 'completed', + startTime: 1, + storageTurnIndex, + }; +} + +function session(overrides: Record = {}): any { + return { + sessionId: SESSION_ID, + dialogTurns: [], + ...overrides, + }; +} + +describe('nextStorageTurnIndex', () => { + it('gives the first turn of an empty session slot zero', () => { + expect(nextStorageTurnIndex(session())).toBe(0); + }); + + it('continues after the highest slot already projected', () => { + expect( + nextStorageTurnIndex(session({ dialogTurns: [turn('a', 0), turn('b', 1)] })), + ).toBe(2); + }); + + it('continues after the catalog when the loaded window is only the tail', () => { + expect( + nextStorageTurnIndex(session({ + dialogTurns: [turn('e', 4)], + isPartial: true, + totalTurnCount: 5, + turnCatalog: { + sessionId: SESSION_ID, + totalTurnCount: 5, + entries: [ + { turnId: 'a', ordinal: 0, storageTurnIndex: 0 }, + { turnId: 'e', ordinal: 4, storageTurnIndex: 4 }, + ], + }, + })), + ).toBe(5); + }); + + it('ignores a catalog belonging to another session', () => { + expect( + nextStorageTurnIndex(session({ + dialogTurns: [turn('a', 0)], + turnCatalog: { + sessionId: 'some-other-session', + totalTurnCount: 9, + entries: [{ turnId: 'x', ordinal: 8, storageTurnIndex: 8 }], + }, + })), + ).toBe(1); + }); + + it('reads the slot of a turn restored under the deprecated field', () => { + const restored = turn('a'); + restored.backendTurnIndex = 3; + expect(nextStorageTurnIndex(session({ dialogTurns: [restored] }))).toBe(4); + }); + + it('refuses to guess while persisted turns are not projected yet', () => { + expect( + nextStorageTurnIndex(session({ + dialogTurns: [], + isPartial: true, + totalTurnCount: 3, + })), + ).toBeUndefined(); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/flowChatTurnIdentity.ts b/src/web-ui/src/flow_chat/utils/flowChatTurnIdentity.ts index 374c3ae726..41a8325e54 100644 --- a/src/web-ui/src/flow_chat/utils/flowChatTurnIdentity.ts +++ b/src/web-ui/src/flow_chat/utils/flowChatTurnIdentity.ts @@ -83,6 +83,35 @@ export function resolveStorageTurnIndex( : undefined; } +/** + * Storage slot for the next Turn of a Session the frontend persists itself. + * + * The local runtime hands out that slot through `DialogTurnStarted`, but an + * ACP agent runs outside it: nothing on the backend counts those Turns, so the + * projection allocates the slot from what it already knows. Returns undefined + * when the Session has persisted Turns none of which are projected yet — + * guessing there would overwrite history. + */ +export function nextStorageTurnIndex( + session: TurnIdentitySession, +): StorageTurnIndex | undefined { + let highest = -1; + for (const turn of session.dialogTurns) { + const storageIndex = turn.storageTurnIndex ?? turn.backendTurnIndex; + if (typeof storageIndex === 'number') { + highest = Math.max(highest, storageIndex); + } + } + for (const entry of validSessionTurnCatalog(session)?.entries ?? []) { + highest = Math.max(highest, entry.storageTurnIndex); + } + + if (highest < 0 && projectedSessionTurnCount(session) > 0) { + return undefined; + } + return asStorageTurnIndex(highest + 1); +} + function buildCatalogOrdinalMaps(session: TurnIdentitySession): { ordinalByTurnId: Map; ordinalByStorageIndex: Map; diff --git a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts index 66fb79476c..f9453beaf0 100644 --- a/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts +++ b/src/web-ui/src/infrastructure/appearance/registry/defaultAppearanceRegistry.ts @@ -247,6 +247,7 @@ import { acpPermissionActionsAppearanceDescriptor } from '@/flow_chat/tool-cards import { contextCompressionDisplayAppearanceDescriptor } from '@/flow_chat/tool-cards/ContextCompressionDisplay.appearance'; import { getToolSpecCardAppearanceDescriptor } from '@/flow_chat/tool-cards/GetToolSpecCard.appearance'; import { viewImageToolCardAppearanceDescriptor } from '@/flow_chat/tool-cards/ViewImageToolCard.appearance'; +import { runCodeToolCardAppearanceDescriptor } from '@/flow_chat/tool-cards/RunCodeToolCard.appearance'; import { acpPlanPanelAppearanceDescriptor } from '@/flow_chat/components/AcpPlanPanel.appearance'; import { backgroundCommandInputDialogAppearanceDescriptor } from '@/flow_chat/components/background-command/BackgroundCommandInputDialog.appearance'; import { chatEmptyStateAppearanceDescriptor } from '@/flow_chat/components/ChatEmptyState.appearance'; @@ -506,6 +507,7 @@ export function createDefaultAppearanceRegistry(): AppearanceRegistry { .registerComponent(contextCompressionDisplayAppearanceDescriptor) .registerComponent(getToolSpecCardAppearanceDescriptor) .registerComponent(viewImageToolCardAppearanceDescriptor) + .registerComponent(runCodeToolCardAppearanceDescriptor) .registerComponent(acpPlanPanelAppearanceDescriptor) .registerComponent(backgroundCommandInputDialogAppearanceDescriptor) .registerComponent(chatEmptyStateAppearanceDescriptor) diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx index ecb5b39de1..9bbc7318f7 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx @@ -548,11 +548,49 @@ describe('AcpAgentsConfig', () => { expect(notifySuccessMock).toHaveBeenCalledWith('notifications.configAddedManualCliRequired'); }); - it('offers DeepSeek Harness as a native ACP preset', async () => { + it('offers the one-click installer for DeepSeek Harness and launches the bundled profile', async () => { probeClientRequirementsMock.mockResolvedValue([ { id: 'dsh', - tool: { name: 'dsh-acp-demo', installed: true, version: '0.1.0-rc.6' }, + tool: { name: 'dsh', installed: false }, + runnable: false, + notes: ['dsh is not available on PATH'], + }, + ]); + + await act(async () => { + root.render(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('DeepSeek Harness'); + // Unlike omp, the harness is a plain npm global, so BitFun installs it. + // The bridge is not a separate adapter — it ships inside BitFun. + expect(container.textContent).not.toContain('registry.adapterMissing'); + + const installButtons = Array.from(container.querySelectorAll('button')) + .filter(button => button.textContent?.includes('actions.installCli')); + expect(installButtons.length).toBeGreaterThan(0); + + await act(async () => { + installButtons[0].click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(installClientCliMock).toHaveBeenCalledWith( + expect.objectContaining({ clientId: 'dsh' }), + ); + }); + + it('adds DeepSeek Harness as a launch of the profile BitFun materializes', async () => { + probeClientRequirementsMock.mockResolvedValue([ + { + id: 'dsh', + tool: { name: 'dsh', installed: true, version: '0.1.0-rc.6' }, runnable: true, notes: [], }, @@ -561,15 +599,35 @@ describe('AcpAgentsConfig', () => { await act(async () => { root.render(); }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + // Address the harness row by name: row order is whatever the saved config + // and the preset list happen to produce, so "the last add button" belongs + // to some other agent as often as not. + const harnessRow = Array.from( + container.querySelectorAll('.bitfun-acp-agents__registry-row'), + ).find(row => row.querySelector('.bitfun-acp-agents__registry-name') + ?.textContent === 'DeepSeek Harness'); + expect(harnessRow).toBeTruthy(); + + const addButton = Array.from(harnessRow!.querySelectorAll('button')) + .find(button => button.textContent?.includes('actions.add')); + expect(addButton).toBeTruthy(); await act(async () => { + addButton!.click(); + await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); - expect(container.textContent).toContain('DeepSeek Harness'); - expect(container.textContent).toContain('dsh-acp-demo'); - expect(container.textContent).not.toContain('registry.acpMissing'); + // The command has to name the profile BitFun materializes; a bare `dsh` + // would drop the user into the harness's own default composition, which + // does not speak ACP at all. + expect(saveJsonConfigMock).toHaveBeenCalledWith(expect.stringContaining('bitfun-acp')); }); it('does not downgrade enabled agents on transient probe timeouts during refresh', async () => { diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx index fa2d307a2d..7c78c0fd64 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx @@ -105,12 +105,15 @@ const PRESETS: AcpClientPreset[] = [ command: 'opencode', args: ['acp'], }, + // BitFun ships the ACP bridge for DeepSeek Harness and installs it into the + // user's own dsh as a profile on first launch, so the only setup left is the + // harness itself and the model the user picks inside it. { id: 'dsh', name: 'DeepSeek Harness', - description: 'Native ACP server (dsh-acp-demo); add --config with an absolute DSH cordis.yml path.', - command: 'dsh-acp-demo', - args: [], + description: 'DeepSeek Harness with BitFun\'s bundled ACP bridge. Uses the model and API key configured in dsh.', + command: 'dsh', + args: ['--profile', 'bitfun-acp'], }, { id: 'omp', diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 2d18188d17..f04f1c492c 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1638,6 +1638,20 @@ "commandInterrupted": "Command interrupted", "executionFailed": "Command execution failed" }, + "runCode": { + "title": "Run Code", + "programLabel": "Program", + "outputLabel": "Output", + "writing": "Writing the program...", + "running": "Running the program...", + "completed": "Program finished", + "failed": "Program failed", + "cancelled": "Program cancelled", + "rejected": "Program rejected", + "copyCode": "Copy code", + "codeCopied": "Code copied", + "copyCodeFailed": "Failed to copy code" + }, "approval": { "waiting": "Waiting for confirmation", "confirm": "Allow", @@ -2380,6 +2394,9 @@ "fastModel": "Fast Model", "fastMode": "Fast mode", "fastModeDescription": "1.5x speed with higher credit usage", + "acpMode": "Mode", + "acpModeLocked": "This conversation has already started, so its mode is fixed.", + "acpModeFailed": "Failed to switch mode", "switchFailed": "Failed to switch model", "modelNotConfigured": "Not Configured", "contextUsage": { diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 30a00303cd..fff3c04459 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1638,6 +1638,20 @@ "commandInterrupted": "命令已被中断", "executionFailed": "命令执行失败" }, + "runCode": { + "title": "运行代码", + "programLabel": "程序", + "outputLabel": "输出", + "writing": "正在编写程序…", + "running": "正在运行程序…", + "completed": "程序已结束", + "failed": "程序执行失败", + "cancelled": "程序已取消", + "rejected": "程序已拒绝", + "copyCode": "复制代码", + "codeCopied": "代码已复制", + "copyCodeFailed": "复制代码失败" + }, "approval": { "waiting": "等待确认", "confirm": "允许", @@ -2380,6 +2394,9 @@ "fastModel": "Fast 模型", "fastMode": "Fast 模式", "fastModeDescription": "1.5 倍速度,消耗更多额度", + "acpMode": "模式", + "acpModeLocked": "会话已经开始,模式不能再改。", + "acpModeFailed": "切换模式失败", "switchFailed": "切换模型失败", "modelNotConfigured": "未配置", "contextUsage": { diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 48726b91d6..f7125d173c 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1638,6 +1638,20 @@ "commandInterrupted": "命令已被中斷", "executionFailed": "命令執行失敗" }, + "runCode": { + "title": "執行程式碼", + "programLabel": "程式", + "outputLabel": "輸出", + "writing": "正在撰寫程式…", + "running": "正在執行程式…", + "completed": "程式已結束", + "failed": "程式執行失敗", + "cancelled": "程式已取消", + "rejected": "程式已拒絕", + "copyCode": "複製程式碼", + "codeCopied": "程式碼已複製", + "copyCodeFailed": "複製程式碼失敗" + }, "approval": { "waiting": "等待確認", "confirm": "允許", @@ -2380,6 +2394,9 @@ "fastModel": "Fast 模型", "fastMode": "Fast 模式", "fastModeDescription": "1.5 倍速度,消耗更多額度", + "acpMode": "模式", + "acpModeLocked": "對話已經開始,模式不能再改。", + "acpModeFailed": "切換模式失敗", "switchFailed": "切換模型失敗", "modelNotConfigured": "未設定", "contextUsage": {