From 297b5611921cc090ff5c773141edbed54ff6b905 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 14 Aug 2026 20:18:26 +0800 Subject: [PATCH 01/11] feat(acp): ship an IDE-facing ACP bridge for DeepSeek Harness The harness has no ACP entry point of its own, and the published @deepseek-ai/dsh-acp is an automation surface: it withholds tool calls, reasoning, and mode selection, because a script does not need to watch an agent think. An IDE needs exactly those. So add packages/dsh-acp and ship it as a dsh PROFILE rather than as something the user installs separately. dsh symlinks its whole dependency closure into $DSH_HOME/profiles/node_modules on every launch, so a profile resolves every harness plugin out of the user's own installation. BitFun therefore carries a compiled bridge, not a second copy of the harness, and launches `dsh --profile bitfun-acp`. The model and the key stay in dsh. cordis.yml mounts the harness's own settings, credentials, and default-model rows, so whatever the user picked in dsh's Models page is what an IDE session runs on. BitFun stores nothing about the account. This replaces the `dsh` preset from #2272, which launched the automation surface under the same id. Two of the changes here are not dsh-specific and apply to every ACP agent: tool calls now render as BitFun's native Bash/Read/Edit cards grouped by round, and the model picker handles an agent that publishes a `mode` option instead of models, collapsing to a locked entry once a conversation has fixed its mode. packages/dsh-acp is deliberately not a pnpm workspace member: it pins the whole harness 0.1.0-rc.6 train and carries its own package-lock.json, so installing the harness is the desktop bundle build's cost rather than every contributor's. prepare-dsh-profile.mjs fails the build rather than shipping a bridgeless app, since the two are indistinguishable until a user starts a session. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 2 + package.json | 1 + packages/dsh-acp/.gitignore | 5 + packages/dsh-acp/NOTICE.md | 30 + packages/dsh-acp/README.md | 93 + packages/dsh-acp/cordis.yml | 195 + packages/dsh-acp/package-lock.json | 5759 +++++++++++++++++ packages/dsh-acp/package.json | 116 + .../dsh-acp/presets/code/agent.cordis.yml | 95 + packages/dsh-acp/presets/code/preset.yml | 3 + .../dsh-acp/presets/minimal/agent.cordis.yml | 58 + packages/dsh-acp/presets/minimal/preset.yml | 3 + .../dsh-acp/presets/standard/agent.cordis.yml | 97 + packages/dsh-acp/presets/standard/preset.yml | 3 + packages/dsh-acp/scripts/build-profile.mjs | 208 + packages/dsh-acp/scripts/link-local-dsh.mjs | 233 + packages/dsh-acp/scripts/smoke.mjs | 157 + packages/dsh-acp/src/app.ts | 200 + packages/dsh-acp/src/bin.ts | 31 + packages/dsh-acp/src/bridge.ts | 812 +++ packages/dsh-acp/src/codec.ts | 70 + packages/dsh-acp/src/tool-view.ts | 159 + packages/dsh-acp/tests/approval-order.spec.ts | 113 + .../tests/fixtures/plugins/contribute.js | 17 + .../fixtures/presets/alpha/agent.cordis.yml | 5 + .../tests/fixtures/presets/alpha/preset.yml | 3 + .../fixtures/presets/beta/agent.cordis.yml | 5 + .../tests/fixtures/presets/beta/preset.yml | 3 + .../tests/fixtures/presets/broken/preset.yml | 6 + packages/dsh-acp/tests/harness.ts | 269 + packages/dsh-acp/tests/lifecycle.spec.ts | 79 + packages/dsh-acp/tests/preset-modes.spec.ts | 172 + packages/dsh-acp/tests/round-trip.spec.ts | 116 + packages/dsh-acp/tests/tool-events.spec.ts | 242 + packages/dsh-acp/tsconfig.build.json | 21 + packages/dsh-acp/tsconfig.json | 21 + scripts/frontend-build-all.mjs | 16 +- scripts/prepare-dsh-profile.mjs | 93 + src/apps/desktop/tauri.conf.json | 3 +- src/apps/desktop/tauri.dev.conf.json | 1 + src/crates/interfaces/acp/Cargo.toml | 4 + .../acp/src/client/builtin_clients.rs | 59 +- .../interfaces/acp/src/client/dsh_profile.rs | 434 ++ .../interfaces/acp/src/client/manager.rs | 18 +- src/crates/interfaces/acp/src/client/mod.rs | 1 + .../interfaces/acp/src/client/stream.rs | 55 +- .../acp/src/client/tool_card_bridge/mod.rs | 119 + .../src/client/tool_card_bridge/tool_name.rs | 46 + .../client/tool_card_bridge/tool_params.rs | 23 +- .../client/tool_card_bridge/tool_result.rs | 317 + .../flow_chat/components/ModelSelector.tsx | 120 +- .../components/ModelSelectorAcpMode.test.tsx | 222 + .../flow_chat/utils/acpSessionConfig.test.ts | 45 + .../src/flow_chat/utils/acpSessionConfig.ts | 32 + .../components/AcpAgentsConfig.test.tsx | 68 +- .../config/components/AcpAgentsConfig.tsx | 9 +- src/web-ui/src/locales/en-US/flow-chat.json | 3 + src/web-ui/src/locales/zh-CN/flow-chat.json | 3 + src/web-ui/src/locales/zh-TW/flow-chat.json | 3 + 59 files changed, 11028 insertions(+), 68 deletions(-) create mode 100644 packages/dsh-acp/.gitignore create mode 100644 packages/dsh-acp/NOTICE.md create mode 100644 packages/dsh-acp/README.md create mode 100644 packages/dsh-acp/cordis.yml create mode 100644 packages/dsh-acp/package-lock.json create mode 100644 packages/dsh-acp/package.json create mode 100644 packages/dsh-acp/presets/code/agent.cordis.yml create mode 100644 packages/dsh-acp/presets/code/preset.yml create mode 100644 packages/dsh-acp/presets/minimal/agent.cordis.yml create mode 100644 packages/dsh-acp/presets/minimal/preset.yml create mode 100644 packages/dsh-acp/presets/standard/agent.cordis.yml create mode 100644 packages/dsh-acp/presets/standard/preset.yml create mode 100644 packages/dsh-acp/scripts/build-profile.mjs create mode 100644 packages/dsh-acp/scripts/link-local-dsh.mjs create mode 100644 packages/dsh-acp/scripts/smoke.mjs create mode 100644 packages/dsh-acp/src/app.ts create mode 100644 packages/dsh-acp/src/bin.ts create mode 100644 packages/dsh-acp/src/bridge.ts create mode 100644 packages/dsh-acp/src/codec.ts create mode 100644 packages/dsh-acp/src/tool-view.ts create mode 100644 packages/dsh-acp/tests/approval-order.spec.ts create mode 100644 packages/dsh-acp/tests/fixtures/plugins/contribute.js create mode 100644 packages/dsh-acp/tests/fixtures/presets/alpha/agent.cordis.yml create mode 100644 packages/dsh-acp/tests/fixtures/presets/alpha/preset.yml create mode 100644 packages/dsh-acp/tests/fixtures/presets/beta/agent.cordis.yml create mode 100644 packages/dsh-acp/tests/fixtures/presets/beta/preset.yml create mode 100644 packages/dsh-acp/tests/fixtures/presets/broken/preset.yml create mode 100644 packages/dsh-acp/tests/harness.ts create mode 100644 packages/dsh-acp/tests/lifecycle.spec.ts create mode 100644 packages/dsh-acp/tests/preset-modes.spec.ts create mode 100644 packages/dsh-acp/tests/round-trip.spec.ts create mode 100644 packages/dsh-acp/tests/tool-events.spec.ts create mode 100644 packages/dsh-acp/tsconfig.build.json create mode 100644 packages/dsh-acp/tsconfig.json create mode 100644 scripts/prepare-dsh-profile.mjs create mode 100644 src/crates/interfaces/acp/src/client/dsh_profile.rs create mode 100644 src/crates/interfaces/acp/src/client/tool_card_bridge/tool_result.rs create mode 100644 src/web-ui/src/flow_chat/components/ModelSelectorAcpMode.test.tsx diff --git a/Cargo.lock b/Cargo.lock index 54d562583f..af7b962c75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -770,8 +770,10 @@ dependencies = [ "bitfun-events", "chrono", "dashmap", + "dirs 6.0.0", "futures", "log", + "semver", "serde", "serde_json", "sha2", 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..e7312740cb --- /dev/null +++ b/packages/dsh-acp/README.md @@ -0,0 +1,93 @@ +# @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). +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. + +## 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..d8a8c91ded --- /dev/null +++ b/packages/dsh-acp/scripts/smoke.mjs @@ -0,0 +1,157 @@ +#!/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. + * + * Usage: `node scripts/smoke.mjs [--mode code] [--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' }, + 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`) + + const session = await client.newSession({ cwd: WORKSPACE, mcpServers: [] }) + const sessionId = session.sessionId + process.stdout.write(`newSession: ${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..32c01bf7d4 --- /dev/null +++ b/packages/dsh-acp/src/bridge.ts @@ -0,0 +1,812 @@ +/** + * 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 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' +// Type-only: the roster is read through `ctx.get`, and this import also carries +// the `agent-preset/selected` session-event declaration the switch below logs. +import type { AgentPreset } from '@deepseek-ai/dsh-agent-presets' +import { SessionId, type SessionEvent, 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 } +} + +/** 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 + } + } + + 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: { + 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 } } + }, + + /** + * 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): 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..38002e4058 --- /dev/null +++ b/packages/dsh-acp/tests/harness.ts @@ -0,0 +1,269 @@ +/** + * 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 * 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 } +} = {}): Promise { + const adapter = new MockAdapter(options.script ?? []) + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' } }) + 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/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/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/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/interfaces/acp/Cargo.toml b/src/crates/interfaces/acp/Cargo.toml index f517b57dde..9e1fda6984 100644 --- a/src/crates/interfaces/acp/Cargo.toml +++ b/src/crates/interfaces/acp/Cargo.toml @@ -11,6 +11,8 @@ client = [ "bitfun-agent-tools/acp-bridge", "dep:futures", "dep:serde", + "dep:dirs", + "dep:semver", "dep:bitfun-core", "bitfun-core/agent-runtime", "bitfun-core/ssh-remote", @@ -55,6 +57,8 @@ 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 } 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..a834cd2ec6 --- /dev/null +++ b/src/crates/interfaces/acp/src/client/dsh_profile.rs @@ -0,0 +1,434 @@ +//! 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. + +use std::path::{Path, PathBuf}; + +use bitfun_core::util::errors::{BitFunError, BitFunResult}; +use serde::Deserialize; + +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 = 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() + )) + })?; + + // 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) +} + +/// 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`." + ))) +} + +/// 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(()) +} + +#[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); + } + + /// 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..b221c5c8f1 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; 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}; @@ -1717,6 +1719,13 @@ impl AcpClientService { connection_id: &str, config: &AcpClientConfig, ) -> BitFunResult<(ByteStreams, Child)> { + // 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 @@ -2728,8 +2737,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. Note that + // materialization only runs in `start_local_transport`, so a remote host + // needs the profile put there by hand until that is wired up too. + 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/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..3b53eb2da2 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,112 @@ 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 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..fda8de9543 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 [ 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..454f4dd440 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 @@ -36,16 +36,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..c191593764 --- /dev/null +++ b/src/crates/interfaces/acp/src/client/tool_card_bridge/tool_result.rs @@ -0,0 +1,317 @@ +//! 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), + _ => result, + } +} + +/// 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 with no search card is passed straight through. + assert_eq!(normalize_tool_result("Bash", prose.clone()), prose); + } + + #[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/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/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/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..7ea82135fd 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -2380,6 +2380,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..50f9cd9b83 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -2380,6 +2380,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..a261a431b8 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -2380,6 +2380,9 @@ "fastModel": "Fast 模型", "fastMode": "Fast 模式", "fastModeDescription": "1.5 倍速度,消耗更多額度", + "acpMode": "模式", + "acpModeLocked": "對話已經開始,模式不能再改。", + "acpModeFailed": "切換模式失敗", "switchFailed": "切換模型失敗", "modelNotConfigured": "未設定", "contextUsage": { From c6b750a5c8faf953b6d4f157ea933a1e8b938a9a Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 14 Aug 2026 20:59:44 +0800 Subject: [PATCH 02/11] fix(acp): unblock CI and materialize the dsh profile on remote hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust job never runs `prepare:dsh-profile`, so `packages/dsh-acp/dist-profile` — a bitfun-desktop Tauri resource — was absent and the build script failed with "resource path doesn't exist" on all three platforms. Create it the same way CI already creates `src/mobile-web/dist`, and teach the preflight check and CONTRIBUTING about it so the same failure is self-explaining locally. The core-boundary check needs the client feature's new optional dependencies in its reviewed list, which is the other half of the red. Then the limitation the PR disclosed: profile materialization ran only in `start_local_transport`, so a remote workspace launched `dsh --profile bitfun-acp` against a host that had no such profile. It now runs for remote transports too — one probe round trip for `$DSH_HOME`, the harness version, and the installed stamp, then the built profile as a single tar stream over the session's own transport when that stamp is stale. Streaming rather than SFTP is what makes this work for container connections, and the stamp travels last so an interrupted upload reads as stale rather than as current. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 2 +- CONTRIBUTING.md | 4 + CONTRIBUTING_CN.md | 3 + Cargo.lock | 1 + packages/dsh-acp/README.md | 8 + scripts/check-build-prereqs.mjs | 14 +- scripts/check-build-prereqs.test.mjs | 36 +- .../core-boundaries/rules/feature-rules.mjs | 8 + src/crates/interfaces/acp/Cargo.toml | 2 + .../interfaces/acp/src/client/dsh_profile.rs | 413 +++++++++++++++++- .../interfaces/acp/src/client/manager.rs | 23 +- 11 files changed, 492 insertions(+), 22 deletions(-) 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 af7b962c75..2b45fc73aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -777,6 +777,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "tar", "tokio", "tokio-util", "uuid", diff --git a/packages/dsh-acp/README.md b/packages/dsh-acp/README.md index e7312740cb..8c287d66f6 100644 --- a/packages/dsh-acp/README.md +++ b/packages/dsh-acp/README.md @@ -49,6 +49,14 @@ 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, and BitFun says so if +they were not. + ## Development ```sh 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/src/crates/interfaces/acp/Cargo.toml b/src/crates/interfaces/acp/Cargo.toml index 9e1fda6984..254beb1715 100644 --- a/src/crates/interfaces/acp/Cargo.toml +++ b/src/crates/interfaces/acp/Cargo.toml @@ -13,6 +13,7 @@ client = [ "dep:serde", "dep:dirs", "dep:semver", + "dep:tar", "dep:bitfun-core", "bitfun-core/agent-runtime", "bitfun-core/ssh-remote", @@ -59,6 +60,7 @@ 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/dsh_profile.rs b/src/crates/interfaces/acp/src/client/dsh_profile.rs index a834cd2ec6..8c509238f0 100644 --- a/src/crates/interfaces/acp/src/client/dsh_profile.rs +++ b/src/crates/interfaces/acp/src/client/dsh_profile.rs @@ -12,12 +12,21 @@ //! //! 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 @@ -61,20 +70,7 @@ struct BridgeStamp { /// 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 = 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() - )) - })?; + 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. @@ -105,6 +101,25 @@ pub(crate) async fn ensure_bundled_profile(profile: &str) -> BitFunResult 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 @@ -302,6 +317,290 @@ fn copy_tree(source: &Path, destination: &Path, skip_stamp: bool) -> std::io::Re 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); + + if probe.version.is_empty() { + return Err(BitFunError::service(format!( + "DeepSeek Harness is not installed on the remote host: `{launcher} --version` produced \ + nothing. Install it there with `npm install -g @deepseek-ai/dsh`; BitFun ships the \ + bridge, not the harness." + ))); + } + 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, + /// First line of `dsh --version`, or empty when dsh is not there at all. + version: 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 'version=%s\\n' \"$({launcher} --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("version=") { + probe.version = 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::*; @@ -416,6 +715,92 @@ mod tests { let _ = std::fs::remove_dir_all(&source); } + #[test] + fn the_remote_probe_answers_all_three_questions_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("dsh --version")); + assert!(script.contains(STAMP_FILENAME)); + + let probe = parse_remote_probe(concat!( + "Welcome to Ubuntu\n", + "profiles=/home/dev/.dsh/profiles\n", + "version=0.1.0-rc.6\n", + r#"stamp={"content":"abc","minDshVersion":"0.1.0-rc.6"}"#, + "\n", + )); + assert_eq!(probe.profiles, "/home/dev/.dsh/profiles"); + assert_eq!(probe.version, "0.1.0-rc.6"); + 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\nversion=\nstamp=\n"); + assert_eq!(missing.version, ""); + assert_eq!(missing.stamp, ""); + } + + #[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. diff --git a/src/crates/interfaces/acp/src/client/manager.rs b/src/crates/interfaces/acp/src/client/manager.rs index b221c5c8f1..dd31100de3 100644 --- a/src/crates/interfaces/acp/src/client/manager.rs +++ b/src/crates/interfaces/acp/src/client/manager.rs @@ -43,7 +43,7 @@ use super::config::{ AcpClientConfig, AcpClientConfigFile, AcpClientInfo, AcpClientPermissionMode, AcpClientRequirementProbe, AcpClientStatus, RemoteAcpClientRequirementSnapshot, }; -use super::dsh_profile::ensure_bundled_profile; +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}; @@ -1807,6 +1807,21 @@ 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 @@ -2738,9 +2753,9 @@ mod tests { .expect("built-in DSH config"); assert_eq!(resolved.command, "dsh"); - // A remote workspace resolves the same launch as a local one. Note that - // materialization only runs in `start_local_transport`, so a remote host - // needs the profile put there by hand until that is wired up too. + // 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); } From a46b557be612982c1f718086a4c22d0cdaca6ccb Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 14 Aug 2026 21:02:27 +0800 Subject: [PATCH 03/11] fix(core-boundaries): teach the self-test about the ACP client's profile deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reviewed feature list lives in two places — the rule and the self-test's mirror of it — and only the rule was updated, so the check passed while the suite that guards it failed. Co-Authored-By: Claude Opus 5 --- scripts/core-boundaries/self-test.mjs | 6 ++++++ 1 file changed, 6 insertions(+) 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', From 77d05ffd2f91515fc2aebfcdcf63caebd6c1638b Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 14 Aug 2026 21:37:39 +0800 Subject: [PATCH 04/11] fix(acp): keep the remote agent's dying words A remote ACP client that exits during startup left nothing in the log but a broken pipe from the first `initialize`, because the launch path copied its stderr into a sink. Log those lines instead: they are the only place the reason ever appears. Co-Authored-By: Claude Opus 5 --- src/crates/interfaces/acp/src/client/manager.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/crates/interfaces/acp/src/client/manager.rs b/src/crates/interfaces/acp/src/client/manager.rs index dd31100de3..80321c9121 100644 --- a/src/crates/interfaces/acp/src/client/manager.rs +++ b/src/crates/interfaces/acp/src/client/manager.rs @@ -1831,10 +1831,20 @@ 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_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; + use tokio::io::AsyncBufReadExt; + let mut lines = tokio::io::BufReader::new(stderr).lines(); + while let Ok(Some(line)) = lines.next_line().await { + if line.trim().is_empty() { + continue; + } + log::warn!("Remote ACP client stderr: id={stderr_client_id} {line}"); + } }); Ok(ByteStreams::new( Box::pin(writer.compat_write()), From bfd525a9eb2c851f89e40feeded2adc322bc780f Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 14 Aug 2026 21:54:05 +0800 Subject: [PATCH 05/11] fix(acp): ask the remote host the same question the agent list asks Materialization decided "installed" from `dsh --version` on stdout, while the agent list decides it from `command -v`. A host where the launcher resolves but answers nothing on stdout therefore showed as available and then refused to launch. Resolve presence with `command -v` and keep the version as best effort on either stream, so an unparsable answer falls through the version gate rather than blocking a working host. Co-Authored-By: Claude Opus 5 --- .../interfaces/acp/src/client/dsh_profile.rs | 42 +++++++++++++++---- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/crates/interfaces/acp/src/client/dsh_profile.rs b/src/crates/interfaces/acp/src/client/dsh_profile.rs index 8c509238f0..25ccde8640 100644 --- a/src/crates/interfaces/acp/src/client/dsh_profile.rs +++ b/src/crates/interfaces/acp/src/client/dsh_profile.rs @@ -362,11 +362,14 @@ pub(crate) async fn ensure_bundled_profile_remote( } let probe = parse_remote_probe(&stdout); - if probe.version.is_empty() { + // 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} --version` produced \ - nothing. Install it there with `npm install -g @deepseek-ai/dsh`; BitFun ships the \ - bridge, not the harness." + "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." ))); } if !version_is_supported(&probe.version, &stamp.min_dsh_version) { @@ -464,7 +467,12 @@ struct RemoteProbe { /// log line only — the install script resolves it again on its own, so the /// two never travel through a quoting round trip. profiles: String, - /// First line of `dsh --version`, or empty when dsh is not there at all. + /// 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, /// The installed build stamp, verbatim, or empty when the profile is new. stamp: String, @@ -490,7 +498,8 @@ fn remote_probe_script(profile: &str, launcher: &str, exports: &str) -> String { format!( "{exports}profiles=\"${{DSH_HOME:-$HOME/.dsh}}/profiles\"\n\ printf 'profiles=%s\\n' \"$profiles\"\n\ - printf 'version=%s\\n' \"$({launcher} --version 2>/dev/null | head -n 1 | tr -d '\\r')\"\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 'stamp=%s\\n' \"$(cat \"$profiles\"/{profile}/{STAMP_FILENAME} 2>/dev/null | tr -d '\\n\\r')\"\n" ) } @@ -503,6 +512,8 @@ fn parse_remote_probe(stdout: &str) -> RemoteProbe { 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("stamp=") { @@ -716,23 +727,28 @@ mod tests { } #[test] - fn the_remote_probe_answers_all_three_questions_in_one_round_trip() { + 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("dsh --version")); + 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")); 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", 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.stamp, @@ -741,9 +757,17 @@ mod tests { // 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\nversion=\nstamp=\n"); + 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] From b114761caaafdce2fa7c40e25423bf21d7d50f2e Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 14 Aug 2026 22:11:10 +0800 Subject: [PATCH 06/11] fix(remote-ssh): stop reading a dropped control handle as a kill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `WorkspaceStdio` documents that the three IO streams are what lease the process, and that `control` and `completion` do not keep it alive by themselves. Both channel loops contradicted that: `control_rx.recv()` returning `None` — which is just the last `WorkspaceProcessControl` sender going out of scope — was matched together with `Kill`, so a caller that drives the process purely over stdio killed it the moment it dropped the handle it had no use for. Remote ACP is exactly that caller. The agent was SIGKILLed microseconds after exec, and the only trace left was a broken pipe on the first `initialize` — no stderr, because it never got to write any. Resolve the arm only on real signals, and cover both loops with a test that dropping the handle lets the process run to its own exit status. --- .../src/remote_ssh/transport.rs | 100 +++++++++++++++--- 1 file changed, 85 insertions(+), 15 deletions(-) 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() { From 607a4b768b85122aa33c396a6ed70ef9d30fb98e Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 14 Aug 2026 22:11:10 +0800 Subject: [PATCH 07/11] fix(acp): log how a remote agent exited An agent that dies mid-session leaves behind the same broken pipe as one that never started. The exit code says which. --- src/crates/interfaces/acp/src/client/manager.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/crates/interfaces/acp/src/client/manager.rs b/src/crates/interfaces/acp/src/client/manager.rs index 80321c9121..cd36e7c169 100644 --- a/src/crates/interfaces/acp/src/client/manager.rs +++ b/src/crates/interfaces/acp/src/client/manager.rs @@ -1831,7 +1831,7 @@ impl AcpClientService { client_id, error )) })?; - let (writer, reader, 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. @@ -1846,6 +1846,16 @@ impl AcpClientService { log::warn!("Remote ACP client stderr: id={stderr_client_id} {line}"); } }); + // 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 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()), From e6bbbd02eafb53d084927b8b12445042dee0d192 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 14 Aug 2026 22:31:54 +0800 Subject: [PATCH 08/11] fix(acp): say why an agent died instead of that it died MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client that exits before answering `initialize` produced one sentence — "exited before initialization completed" — which tells the user only what they already know. Its stderr held the whole explanation and went to a log file, or, for a local agent, to a terminal a packaged app does not have. Read both transports' stderr into the log line by line, keep the tail, and quote it in the error the user is shown. Waiting for EOF first is what makes the quote complete: the pipes closing is the signal that the agent is gone, and its last lines are still in flight then. Also ask the remote host for its Node version in the probe round trip we already make, and refuse the launch when it is below 20.12 — the release that added `util.parseEnv`, which the harness imports on its first line. dsh declares no `engines`, so npm installs it onto Node 18 without a word and the failure surfaces from deep inside the launcher. --- packages/dsh-acp/README.md | 10 +- .../interfaces/acp/src/client/dsh_profile.rs | 67 ++++++ .../interfaces/acp/src/client/manager.rs | 211 +++++++++++++++--- 3 files changed, 260 insertions(+), 28 deletions(-) diff --git a/packages/dsh-acp/README.md b/packages/dsh-acp/README.md index 8c287d66f6..cf1c7294b1 100644 --- a/packages/dsh-acp/README.md +++ b/packages/dsh-acp/README.md @@ -14,7 +14,11 @@ inside the harness the user already installed. 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). + 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. @@ -54,8 +58,8 @@ 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, and BitFun says so if -they were not. +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. ## Development diff --git a/src/crates/interfaces/acp/src/client/dsh_profile.rs b/src/crates/interfaces/acp/src/client/dsh_profile.rs index 25ccde8640..fcd23dc5a8 100644 --- a/src/crates/interfaces/acp/src/client/dsh_profile.rs +++ b/src/crates/interfaces/acp/src/client/dsh_profile.rs @@ -139,6 +139,28 @@ async fn require_supported_dsh(minimum: &str) -> BitFunResult<()> { ))) } +/// 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. @@ -372,6 +394,16 @@ pub(crate) async fn ensure_bundled_profile_remote( 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 \ @@ -474,6 +506,9 @@ struct RemoteProbe { /// 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, } @@ -500,6 +535,7 @@ fn remote_probe_script(profile: &str, launcher: &str, exports: &str) -> String { 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" ) } @@ -516,6 +552,8 @@ fn parse_remote_probe(stdout: &str) -> RemoteProbe { 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(); } @@ -737,6 +775,9 @@ mod tests { // 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!( @@ -744,12 +785,14 @@ mod tests { "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"}"# @@ -770,6 +813,30 @@ mod tests { 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"); diff --git a/src/crates/interfaces/acp/src/client/manager.rs b/src/crates/interfaces/acp/src/client/manager.rs index cd36e7c169..28641b80b2 100644 --- a/src/crates/interfaces/acp/src/client/manager.rs +++ b/src/crates/interfaces/acp/src/client/manager.rs @@ -627,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; @@ -698,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(_) => { @@ -1718,7 +1721,11 @@ 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) @@ -1732,7 +1739,11 @@ impl AcpClientService { .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); @@ -1764,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, )) } @@ -1780,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)), } } @@ -1799,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()) @@ -1807,7 +1828,6 @@ 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) @@ -1821,7 +1841,6 @@ impl AcpClientService { ) .await?; } - let transport = ssh_manager .open_workspace_stdio(remote_connection_id, &command) .await @@ -1835,17 +1854,7 @@ impl AcpClientService { // 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_client_id = client_id.to_string(); - 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 { - if line.trim().is_empty() { - continue; - } - log::warn!("Remote ACP client stderr: id={stderr_client_id} {line}"); - } - }); + 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(); @@ -1856,9 +1865,9 @@ impl AcpClientService { 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, )) } @@ -2552,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 { @@ -2696,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 { From cc59f996dbf68247c96e4390b566f678b11e08ac Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 14 Aug 2026 22:55:44 +0800 Subject: [PATCH 09/11] fix(acp): show a code-mode step as the program it ran MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek Harness's PTC preset answers a whole step with one `run_code` call whose argument is a TypeScript program. The bridge classified it by kind alone, so it landed on the terminal card, which reads `command` — a field the call does not have — and drew an empty card. Give it its own identity and card: `run_code` (and anything shaped like it: Execute kind, a `code` argument, no `command`) becomes `RunCode`, rendered as the program plus what it printed. A shell call that happens to carry a `code` argument is still Bash. While reading the result path: ACP results carry their text in content blocks, but the terminal card reads `output`/`stdout`, so every ACP Bash card showed the command with nothing underneath it. Lift that text into `output` for both cards. Co-Authored-By: Claude Opus 5 --- .../acp/src/client/tool_card_bridge/mod.rs | 44 +++++ .../src/client/tool_card_bridge/tool_name.rs | 13 ++ .../client/tool_card_bridge/tool_params.rs | 12 ++ .../client/tool_card_bridge/tool_result.rs | 46 ++++- .../tool-cards/RunCodeToolCard.appearance.ts | 13 ++ .../flow_chat/tool-cards/RunCodeToolCard.scss | 30 +++ .../tool-cards/RunCodeToolCard.test.tsx | 157 +++++++++++++++ .../flow_chat/tool-cards/RunCodeToolCard.tsx | 178 ++++++++++++++++++ src/web-ui/src/flow_chat/tool-cards/index.ts | 4 + .../flow_chat/tool-cards/toolCardMetadata.ts | 14 ++ .../registry/defaultAppearanceRegistry.ts | 2 + src/web-ui/src/locales/en-US/flow-chat.json | 14 ++ src/web-ui/src/locales/zh-CN/flow-chat.json | 14 ++ src/web-ui/src/locales/zh-TW/flow-chat.json | 14 ++ 14 files changed, 553 insertions(+), 2 deletions(-) create mode 100644 src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.appearance.ts create mode 100644 src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.scss create mode 100644 src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.test.tsx create mode 100644 src/web-ui/src/flow_chat/tool-cards/RunCodeToolCard.tsx 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 3b53eb2da2..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 @@ -214,6 +214,50 @@ mod tests { 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 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 fda8de9543..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 @@ -96,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(); } @@ -237,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(), @@ -254,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 454f4dd440..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 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 index c191593764..713dd88e9b 100644 --- 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 @@ -19,10 +19,30 @@ 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 { @@ -302,8 +322,30 @@ mod tests { 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 with no search card is passed straight through. - assert_eq!(normalize_tool_result("Bash", 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] 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/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/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 7ea82135fd..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", 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 50f9cd9b83..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": "允许", 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 a261a431b8..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": "允許", From ac23982937aa98e10acaebe28b521dc31239056d Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Sat, 15 Aug 2026 00:33:21 +0800 Subject: [PATCH 10/11] fix(flow-chat): keep an ACP conversation on disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An ACP session came back empty after a restart. Its turns were never written, and three layers each had a reason. The projection is the only writer of these turns, but it had no storage slot to write into. That slot arrives with the backend's `DialogTurnStarted`, and no such event exists for a turn an external agent runs — nothing in the local runtime starts it. Every save was therefore deferred, forever. Allocate the slot in the projection instead, from the turns and catalog it already holds, and decline to guess when the session has persisted turns none of which are projected yet, where a guess would overwrite history. The backend then refused the save it did receive. `save_persisted_dialog_turn` validates a turn against the runtime's history branch for that session, and an externally driven session has none — the runtime neither starts nor completes those turns — so a first turn failed with OutcomeUnknown. Read the session's `provider` metadata and, when an external agent owns it, persist straight through. A runtime-owned session still needs its branch, which the new test pins from both sides. Finally, the desktop path loaded every session into the session manager before saving, which for an ACP session restored nothing and rewrote its persisted mode to a local fallback. Skip the load for the same reason: there is no runtime state to restore. The `provider` key and its `acp` value move into core-types, so the ACP client that writes them and the runtime that reads them back share one definition rather than two string literals that have to agree. Co-Authored-By: Claude Opus 5 --- .../src/runtime/session_application.rs | 15 +- .../assembly/core/src/product_runtime.rs | 171 +++++++++++++++++- src/crates/contracts/core-types/src/lib.rs | 2 +- .../contracts/core-types/src/session.rs | 10 + .../acp/src/client/session_persistence.rs | 12 +- .../services/services-core/src/session/mod.rs | 2 +- .../local/LocalSessionDriver.test.ts | 141 +++++++++++++++ .../local/LocalSessionDriver.ts | 18 +- .../utils/flowChatTurnIdentity.test.ts | 85 +++++++++ .../flow_chat/utils/flowChatTurnIdentity.ts | 29 +++ 10 files changed, 475 insertions(+), 10 deletions(-) create mode 100644 src/web-ui/src/flow_chat/session-drivers/local/LocalSessionDriver.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/flowChatTurnIdentity.test.ts 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/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/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/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/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/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; From 4c3866361ba40c264f86085110b51495b5caed19 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Sat, 15 Aug 2026 00:49:42 +0800 Subject: [PATCH 11/11] feat(acp): reopen a stored DeepSeek Harness conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing a dsh session and clicking it again gave a blank one: the bridge never advertised ACP's `loadSession`, so BitFun had only `session/new` to fall back on. Every one of a user's stored sessions carries `acpResumeStrategy: "new"` for that reason. The reopened conversation lost its history and its context, and its mode picker unlocked and reverted to the roster default — a session that has already spoken must not be able to change the composition its transcript was written under. Implement `session/load`. A live session replays from memory; a cold one resumes out of the harness's own persistence, which is the case that matters, since a client restart is exactly when nothing is in memory. The archive is read through `inspect`, not a listing: a session disposed a moment ago is still draining, and `list` does not wait for it while `inspect` does — reopening the session you just closed is the first thing a user does. The mode comes back from the session's own log rather than the roster, so a conversation started under a preset reopens under it however the default has moved, and `presetOptions` locks the picker for a session whose conversation has started. A session is refused when it was never stored, or when it belongs to another directory — answering the latter would hand back a session whose sandbox boundary points somewhere else. `scripts/smoke.mjs --load ` drives the path against a real installation. Co-Authored-By: Claude Opus 5 --- packages/dsh-acp/README.md | 27 ++++ packages/dsh-acp/scripts/smoke.mjs | 21 ++- packages/dsh-acp/src/bridge.ts | 149 +++++++++++++++++- packages/dsh-acp/tests/harness.ts | 12 ++ packages/dsh-acp/tests/session-load.spec.ts | 160 ++++++++++++++++++++ 5 files changed, 360 insertions(+), 9 deletions(-) create mode 100644 packages/dsh-acp/tests/session-load.spec.ts diff --git a/packages/dsh-acp/README.md b/packages/dsh-acp/README.md index cf1c7294b1..0f92460651 100644 --- a/packages/dsh-acp/README.md +++ b/packages/dsh-acp/README.md @@ -61,6 +61,33 @@ 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 diff --git a/packages/dsh-acp/scripts/smoke.mjs b/packages/dsh-acp/scripts/smoke.mjs index d8a8c91ded..ca374c5dd6 100644 --- a/packages/dsh-acp/scripts/smoke.mjs +++ b/packages/dsh-acp/scripts/smoke.mjs @@ -21,7 +21,13 @@ * switching the agent preset. It stays in the credential-free half: a mode * switch is answered by the bridge and never reaches the provider. * - * Usage: `node scripts/smoke.mjs [--mode code] [--prompt "…"] [--reject] [--cancel-after 3000]` + * `--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' @@ -37,6 +43,7 @@ const { values } = parseArgs({ args: process.argv.slice(2), options: { prompt: { type: 'string' }, + load: { type: 'string' }, mode: { type: 'string' }, profile: { type: 'string' }, cwd: { type: 'string' }, @@ -116,10 +123,16 @@ 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`) - - const session = await client.newSession({ cwd: WORKSPACE, mcpServers: [] }) + 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(`newSession: ${sessionId}\n`) + process.stdout.write(`${values.load === undefined ? 'newSession' : 'loadSession'}: ${sessionId}\n`) process.stdout.write(`modes: ${describeOptions(session.configOptions)}\n`) if (values.mode !== undefined) { diff --git a/packages/dsh-acp/src/bridge.ts b/packages/dsh-acp/src/bridge.ts index 32c01bf7d4..d1f36d22ab 100644 --- a/packages/dsh-acp/src/bridge.ts +++ b/packages/dsh-acp/src/bridge.ts @@ -32,6 +32,8 @@ import { type CancelNotification, type InitializeRequest, type InitializeResponse, + type LoadSessionRequest, + type LoadSessionResponse, type NewSessionRequest, type NewSessionResponse, type PlanEntry, @@ -47,10 +49,13 @@ import { type ToolCallUpdate, } from '@agentclientprotocol/sdk' import type { Agent } from '@deepseek-ai/dsh-agent' -// Type-only: the roster is read through `ctx.get`, and this import also carries -// the `agent-preset/selected` session-event declaration the switch below logs. -import type { AgentPreset } from '@deepseek-ai/dsh-agent-presets' -import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +// 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' @@ -84,6 +89,24 @@ interface DefaultModelSource { 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) @@ -459,6 +482,40 @@ export function apply(ctx: Context, config: AcpConfig): void { } } + /** + * 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 @@ -542,6 +599,11 @@ export function apply(ctx: Context, config: AcpConfig): void { 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: [], @@ -597,6 +659,83 @@ export function apply(ctx: Context, config: AcpConfig): void { 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. * @@ -803,7 +942,7 @@ function agentOptions(ctx: Context, config: AcpConfig): { provider?: string; mod } /** Reject session features outside this bridge's contract. */ -function validateSessionParams(params: NewSessionRequest): void { +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') diff --git a/packages/dsh-acp/tests/harness.ts b/packages/dsh-acp/tests/harness.ts index 38002e4058..7ec8a50c8f 100644 --- a/packages/dsh-acp/tests/harness.ts +++ b/packages/dsh-acp/tests/harness.ts @@ -26,6 +26,7 @@ import { 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' @@ -140,10 +141,21 @@ export async function makeBridgeHarness(options: { * 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) { 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) + }) +})