From bc071aa1f2ea8de03bea6d2dc13e238aa2cf7669 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Mon, 12 Oct 2026 13:50:31 +0200 Subject: [PATCH 1/4] wip(dsh): host services for a headless DSH runtime (fs, attachments, credentials, storage, session persistence, skills) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- .../yoagent-rutis/examples/ecosystem_probe.rs | 53 +- .../plugins/dsh/host-services.json | 9 + .../plugins/dsh/package-lock.json | 1281 +++++++++++++++-- .../yoagent-rutis/plugins/dsh/package.json | 7 + 4 files changed, 1228 insertions(+), 122 deletions(-) create mode 100644 integrations/yoagent-rutis/plugins/dsh/host-services.json diff --git a/integrations/yoagent-rutis/examples/ecosystem_probe.rs b/integrations/yoagent-rutis/examples/ecosystem_probe.rs index 5f31c45..5cecd29 100644 --- a/integrations/yoagent-rutis/examples/ecosystem_probe.rs +++ b/integrations/yoagent-rutis/examples/ecosystem_probe.rs @@ -28,7 +28,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use rutis::{Ctx, FiberState}; -use rutis_bridge::runtime::LocalRuntime; +use rutis_bridge::runtime::{Launcher, LocalRuntime}; use rutis_loader::{ Chain, EntryStatus, Layer, Loader, LoaderOptions, LoaderPlugin, Patch, RuntimeResolver, RuntimeRowsPlugin, ServiceCatalog, @@ -86,11 +86,25 @@ struct Host { /// A rutis root with the bridge and one Node runtime whose `package.json` is /// `package_json` (where row names resolve). async fn host(runtime: &Path, package_json: &Path) -> Result { + host_with(runtime, package_json, &[]).await +} + +/// [`host`], the runtime's process given `env` (DSH's `DSH_HOME`). +async fn host_with( + runtime: &Path, + package_json: &Path, + env: &[(&str, &Path)], +) -> Result { let root = Ctx::root()?; let bridge = RutisBridge::install(&root)?; let mut catalog = ServiceCatalog::new(); catalog.register_shared("yoagent"); - let node = LocalRuntime::node(runtime, package_json); + let launcher = env + .iter() + .fold(Launcher::node(runtime), |launcher, (name, value)| { + launcher.env(*name, *value) + }); + let node = LocalRuntime::launcher("node", launcher, package_json); let resolver = Arc::new(RuntimeResolver::node(node.handle()).with_catalog(&catalog)); root.plugin(node); let plugin = LoaderPlugin::new( @@ -348,19 +362,38 @@ const SERVICE_CHECK: &str = "probe-services"; /// DSH's base packages and the tools adapter: what every DSH plugin gets. fn dsh_base_rows(base: &Path) -> Value { let adapter = dsh_file(base, "dsh-tools-adapter.ts"); - json!([ - { "id": "base-web", "name": "@deepseek-ai/dsh-web" }, - { "id": "base-system-prompt", "name": "@deepseek-ai/dsh-system-prompt" }, - { "id": "base-tools", "name": "@deepseek-ai/dsh-tools" }, - { "id": "base-user-questions", "name": "@deepseek-ai/dsh-user-questions" }, - { "id": "base-adapter", "name": adapter }, - ]) + // The services a headless DSH host runs (fs, attachments, credentials, + // storage, session persistence, skills): DSH's own packages, listed in + // `plugins/dsh/host-services.json` for every host to load. + let services: Vec = + std::fs::read_to_string(plugins_dir().join("dsh/host-services.json")) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .expect("plugins/dsh/host-services.json"); + let mut rows = services; + rows.extend([ + json!({ "id": "base-web", "name": "@deepseek-ai/dsh-web" }), + json!({ "id": "base-system-prompt", "name": "@deepseek-ai/dsh-system-prompt" }), + json!({ "id": "base-tools", "name": "@deepseek-ai/dsh-tools" }), + json!({ "id": "base-user-questions", "name": "@deepseek-ai/dsh-user-questions" }), + json!({ "id": "base-adapter", "name": adapter }), + ]); + Value::Array(rows) } async fn probe_dsh(logs: Logs, base: PathBuf, rows: Value, inject: Vec) -> Value { let mut result = json!({ "ecosystem": "dsh" }); let runtime = base.join("node_modules/@arcships/rutis-runtime"); - let host = match host(&runtime, &base.join("package.json")).await { + // A DSH home of its own: the user's real one (its credentials) is never + // handed to the plugins probed. + let dsh_home = tempfile::tempdir().expect("a temp DSH_HOME"); + let host = match host_with( + &runtime, + &base.join("package.json"), + &[("DSH_HOME", dsh_home.path())], + ) + .await + { Ok(host) => host, Err(e) => { result["error"] = json!(format!("host: {e}")); diff --git a/integrations/yoagent-rutis/plugins/dsh/host-services.json b/integrations/yoagent-rutis/plugins/dsh/host-services.json new file mode 100644 index 0000000..5e6614c --- /dev/null +++ b/integrations/yoagent-rutis/plugins/dsh/host-services.json @@ -0,0 +1,9 @@ +[ + { "id": "dsh-fs", "name": "@deepseek-ai/dsh-fs-local" }, + { "id": "dsh-attachments", "name": "@deepseek-ai/dsh-attachment-local" }, + { "id": "dsh-credentials", "name": "@deepseek-ai/dsh-credentials-local" }, + { "id": "dsh-storage", "name": "@deepseek-ai/dsh-storage" }, + { "id": "dsh-session-persistence", "name": "@deepseek-ai/dsh-session-persistence" }, + { "id": "dsh-skills", "name": "@deepseek-ai/dsh-skill" }, + { "id": "dsh-skill-filesystem", "name": "@deepseek-ai/dsh-skill-filesystem" } +] diff --git a/integrations/yoagent-rutis/plugins/dsh/package-lock.json b/integrations/yoagent-rutis/plugins/dsh/package-lock.json index 29d514d..11c7931 100644 --- a/integrations/yoagent-rutis/plugins/dsh/package-lock.json +++ b/integrations/yoagent-rutis/plugins/dsh/package-lock.json @@ -9,7 +9,14 @@ "@arcships/rutis": "0.9.1", "@arcships/rutis-runtime": "0.9.1", "@deepseek-ai/cordis": "4.0.4", + "@deepseek-ai/dsh-attachment-local": "0.2.0-rc.2", + "@deepseek-ai/dsh-credentials-local": "0.2.0-rc.2", + "@deepseek-ai/dsh-fs-local": "0.2.0-rc.2", + "@deepseek-ai/dsh-session-persistence": "0.2.0-rc.2", "@deepseek-ai/dsh-settings": "0.2.0-rc.2", + "@deepseek-ai/dsh-skill": "0.2.0-rc.2", + "@deepseek-ai/dsh-skill-filesystem": "0.2.0-rc.2", + "@deepseek-ai/dsh-storage": "0.2.0-rc.2", "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2", "@deepseek-ai/dsh-tool-ask-user": "0.2.0-rc.2", "@deepseek-ai/dsh-tools": "0.2.0-rc.2", @@ -181,6 +188,33 @@ "@deepseek-ai/cordis": "~4.0.4" } }, + "node_modules/@deepseek-ai/dsh-attachment": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-attachment/-/dsh-attachment-0.2.0-rc.2.tgz", + "integrity": "sha512-asUDLv+mfb30vjjlQtMI9aBnC+kmPoUxS1ooadhwAd8wssIlLetq97nzC5W1qd0GQGwZtPG/gpljVAMcSwPr1g==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-brand": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-attachment-local": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-attachment-local/-/dsh-attachment-local-0.2.0-rc.2.tgz", + "integrity": "sha512-VDyHGvKoN59D+3t3rwkZYNFJJJ1PbCV/6z9ngL5lErd1H98NqmFqFzGgGypGPvBGdmi42ckhVldBPq8ZJWzKcA==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-lazy-require": "0.2.0-rc.2", + "@deepseek-ai/schemastery": "~3.18.4", + "sharp": "^0.35.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-attachment": "0.2.0-rc.2", + "@deepseek-ai/dsh-home-paths": "0.2.0-rc.2" + } + }, "node_modules/@deepseek-ai/dsh-brand": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-brand/-/dsh-brand-0.2.0-rc.2.tgz", @@ -213,6 +247,67 @@ } } }, + "node_modules/@deepseek-ai/dsh-credentials": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-credentials/-/dsh-credentials-0.2.0-rc.2.tgz", + "integrity": "sha512-TQlvP0jxXYGy1ZNzh4nVsiCzb8FR9iGarD6BfIZ3LwJC8lDtjSxQHWE8//NYjpn10TIMxmaR4s6niHuJ3JkTOw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@deepseek-ai/dsh-brand": "0.2.0-rc.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-credentials-local": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-credentials-local/-/dsh-credentials-local-0.2.0-rc.2.tgz", + "integrity": "sha512-az83NVgu0COpjrPRAbXs0+evmSznQN1c8tFv/JKVvgcyPeFw5nLK5+0kA2QRBQ2g1p0+1x5H/4XBtr3Lwq/Piw==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4", + "chokidar": "^4.0.3", + "yaml": "^2.9.0" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-atomic-write": "0.2.0-rc.2", + "@deepseek-ai/dsh-credentials": "0.2.0-rc.2", + "@deepseek-ai/dsh-home-paths": "0.2.0-rc.2", + "@deepseek-ai/dsh-launch-environment": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-fs": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs/-/dsh-fs-0.2.0-rc.2.tgz", + "integrity": "sha512-PuVcI7drTwa19Key54DROoqnDFTTFkl8TPlyrTnS3BSnykm+jKbB13ES/PQEJoRunNdQkNZa12jtbUI4BP/EHw==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-brand": "0.2.0-rc.2", + "@deepseek-ai/dsh-invariants": "0.2.0-rc.2", + "@deepseek-ai/dsh-llm": "0.2.0-rc.2", + "@deepseek-ai/dsh-sandbox": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-fs-local": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-fs-local/-/dsh-fs-local-0.2.0-rc.2.tgz", + "integrity": "sha512-lDzh5VlQsXf3nI+E0+T0weYdH1VAoo+htj6Kwc62P7/Lx+xjnYSLQYTeGeaJR1d8H2iQv2FvlwaDKv60AorKdA==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4", + "chokidar": "^4.0.3", + "koffi": "3.1.1" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-fs": "0.2.0-rc.2" + } + }, "node_modules/@deepseek-ai/dsh-home-paths": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-home-paths/-/dsh-home-paths-0.2.0-rc.2.tgz", @@ -246,6 +341,15 @@ "@deepseek-ai/cordis": "~4.0.4" } }, + "node_modules/@deepseek-ai/dsh-lazy-require": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-lazy-require/-/dsh-lazy-require-0.2.0-rc.2.tgz", + "integrity": "sha512-t6m+13fe+4veaepjGwNu0BLTtqdrSPq+A/zmOzC+7DXgWCBHJ3IxfW2vUlLviKfviwMI4BqQMPv4wHeuJgYFIw==", + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, "node_modules/@deepseek-ai/dsh-llm": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm/-/dsh-llm-0.2.0-rc.2.tgz", @@ -348,6 +452,21 @@ "@deepseek-ai/dsh-scope": "0.2.0-rc.2" } }, + "node_modules/@deepseek-ai/dsh-session-persistence": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-persistence/-/dsh-session-persistence-0.2.0-rc.2.tgz", + "integrity": "sha512-2OoRHZYWi5Vy0+1bPU37Y/NTh6uQiZ3VdpvDmW1oCgsapQr2JS/Lbody1gXOMJ1KXglCSLmePgTMC00uKw/UIg==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-util-values": "0.2.0-rc.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-brand": "0.2.0-rc.2", + "@deepseek-ai/dsh-session": "0.2.0-rc.2", + "@deepseek-ai/dsh-timeout": "0.2.0-rc.2" + } + }, "node_modules/@deepseek-ai/dsh-session-projection": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session-projection/-/dsh-session-projection-0.2.0-rc.2.tgz", @@ -382,6 +501,75 @@ "@deepseek-ai/schemastery": "~3.18.4" } }, + "node_modules/@deepseek-ai/dsh-skill": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-skill/-/dsh-skill-0.2.0-rc.2.tgz", + "integrity": "sha512-3QfzZ6qWfESq1TRoVjEfoBRCTONRLU9aLopKpA7hJg/LZ2GqCgMoSvOwJJL+q2Djbs3oiUyC9qJHRnnN6EacIA==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-util-values": "0.2.0-rc.2", + "@deepseek-ai/schemastery": "~3.18.4" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-llm": "0.2.0-rc.2", + "@deepseek-ai/dsh-scope": "0.2.0-rc.2" + } + }, + "node_modules/@deepseek-ai/dsh-skill-filesystem": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-skill-filesystem/-/dsh-skill-filesystem-0.2.0-rc.2.tgz", + "integrity": "sha512-V/88b4uEFwhCAewQhI/VE7/IvgBkDQb//3/zQjK7xieZ+D9wVpOc5AY8GCcQThZu5aGBnI1gX5ITHPlUHjKUtQ==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "~3.18.4", + "chokidar": "^5.0.0", + "yaml": "^2.4.2" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4", + "@deepseek-ai/dsh-fs": "0.2.0-rc.2", + "@deepseek-ai/dsh-home-paths": "0.2.0-rc.2", + "@deepseek-ai/dsh-skill": "0.2.0-rc.2" + } + }, + "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-storage": { + "version": "0.2.0-rc.2", + "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-storage/-/dsh-storage-0.2.0-rc.2.tgz", + "integrity": "sha512-tItvFJ1IAurwmZfBdrr8w+GuyLADZXZ01bos06Ma0QmD3+0NNyvo0E9qmYb54TfJ2h5yvRcFosHO1048gzuPPw==", + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "~4.0.4" + } + }, "node_modules/@deepseek-ai/dsh-system-prompt": { "version": "0.2.0-rc.2", "resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-system-prompt/-/dsh-system-prompt-0.2.0-rc.2.tgz", @@ -540,6 +728,16 @@ "@standard-schema/spec": "^1.1.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -966,131 +1164,895 @@ "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, - "node_modules/@standard-schema/spec": { + "node_modules/@img/colour": { "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/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "engines": { + "node": ">=18" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, - "node_modules/dsh-free-search": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/dsh-free-search/-/dsh-free-search-0.8.1.tgz", - "integrity": "sha512-WJ26G+DFrxnrPQXs2IiCH0jApV2SDsAKTAMV5j/KnMqWXME9LPPvCz7wwMAHY7NTkCoZBz8BjIb+3vEAfpZ2JQ==", - "license": "MIT", - "dependencies": { - "@deepseek-ai/schemastery": "^3.18.4" - }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=20" + "node": ">=20.9.0" }, - "peerDependencies": { - "@deepseek-ai/cordis": "^4.0.1", - "@deepseek-ai/dsh-client-ui-commands": "^0.1.7-rc.1 || ^0.2.0-rc.1", - "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.1.7-rc.1 || ^0.2.0-rc.1", - "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.1 || ^0.2.0-rc.1", - "@deepseek-ai/dsh-settings": "^0.1.7-rc.1 || ^0.2.0-rc.1", - "@deepseek-ai/dsh-tools": "^0.1.7-rc.1 || ^0.2.0-rc.1", - "@deepseek-ai/schemastery": "^3.18.4", - "react": "^18.2.0" + "funding": { + "url": "https://opencollective.com/libvips" }, - "peerDependenciesMeta": { - "@deepseek-ai/cordis": { - "optional": true - }, - "@deepseek-ai/dsh-client-ui-commands": { - "optional": true - }, - "@deepseek-ai/dsh-client-ui-plugin-manager": { - "optional": true - }, - "@deepseek-ai/dsh-client-ui-renderer": { - "optional": true - }, - "@deepseek-ai/dsh-settings": { - "optional": true - }, - "@deepseek-ai/dsh-tools": { - "optional": true - }, - "@deepseek-ai/schemastery": { - "optional": true - }, - "react": { - "optional": true - } + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, - "node_modules/esbuild": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", - "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], "engines": { - "node": ">=18" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" }, "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" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT", - "peer": true - }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@koromix/koffi-darwin-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-arm64/-/koffi-darwin-arm64-3.1.1.tgz", + "integrity": "sha512-+Dl0zQDh1Wb55AWOn9hp7K30qgkODvrvN+ZNkFOh81Q0oFX/rpJQtocgjAuYk2zFAcajSeVDumkcHMPwnKSXzA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-darwin-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-darwin-x64/-/koffi-darwin-x64-3.1.1.tgz", + "integrity": "sha512-cDFAKn1qdZBFLrp7dAc9QUDw3l4xAhTJbOdPWWb0LxssVicUdHcRCLZGrDsmPW2tpH6LGNNeLgqRpAoD2Mo8iA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-arm64/-/koffi-freebsd-arm64-3.1.1.tgz", + "integrity": "sha512-zaP7FJISI/scQW9Wa5QicY3a09WmtKBWSbmC+5nfCqPzwWe7Hx2so74Er7mPsDfCiMMR0Ya+evKbJQDkfyXicg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-ia32/-/koffi-freebsd-ia32-3.1.1.tgz", + "integrity": "sha512-7GejVb688TLM8rbjfc0oezJrATxZc0dn801xWEDJekN2DgmRXu7HquGqWQ6z3NeSq7ZxEggz4T3xtlbCysQapA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-freebsd-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-freebsd-x64/-/koffi-freebsd-x64-3.1.1.tgz", + "integrity": "sha512-XLiCFP9OFCyOoGTjAimtDKLhzhfo34WcP1ShVWxRzNCWDGjfz8BYjwd69cp/cDSUXZbxamqs4+/6vmkePq9wxA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-arm64/-/koffi-linux-arm64-3.1.1.tgz", + "integrity": "sha512-HA9xINK7G4dRAkpfnBWD9VfuyIBgW1SuK+KPHjksUwRMOnhgqP8J/JqgrAzdzcDiefGBkqEacIP776OUwz7knQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-ia32/-/koffi-linux-ia32-3.1.1.tgz", + "integrity": "sha512-jG7IFytmP8K5Qtbx0ro0ZeuX3JjSsLxmYhq+nmXDdrtOAlxIsWGynuiDLS6Jk3vOchVii2m6Y2f/L3GLG2fG5A==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-loong64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-loong64/-/koffi-linux-loong64-3.1.1.tgz", + "integrity": "sha512-CIsT1cNnih8FuU52Me/IVlJBpH28SQfoDeYPctJswgJzaARktusF7m4MUbtR1PBDjuquCVM4/vFyNdOzfPonvA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-riscv64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-riscv64/-/koffi-linux-riscv64-3.1.1.tgz", + "integrity": "sha512-9D6RmqeKsSvs3U6jILJU9PcAjMwKKyn7yLxNBb5k6z9PCoUoGJ3/BrhXAX0qjrLLwEiIpP/hS/40RuXvH8Lc3Q==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-linux-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-linux-x64/-/koffi-linux-x64-3.1.1.tgz", + "integrity": "sha512-pyTcX5fePeYbt7TZAwRby69wdlRx3PT+g15ra5IYdat/Pgh3qAKEYeZ+uu7WpPGOy43p/oSRqqZoa2kORzozlA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-ia32/-/koffi-openbsd-ia32-3.1.1.tgz", + "integrity": "sha512-iPnPzvG2HOfdzaiG1drdkt86sAqmTPDv9mAf+5gL7mRzkeeQC88EVGboRy7eXwdXn7R+v0ntA3iQxdHrBn6yXw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-openbsd-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-openbsd-x64/-/koffi-openbsd-x64-3.1.1.tgz", + "integrity": "sha512-/Xqc3R0SVoMCYjMPZnJ9bULtRo364+dKmnQhfDrI83tSpxUHRw7HRNf12vBeL+hPgKxSBjtMpWfQ/ZIyVyLFag==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-arm64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-arm64/-/koffi-win32-arm64-3.1.1.tgz", + "integrity": "sha512-JhqHauEwQvdcWUERxrV5HH/DT9W7hY1A1eU6/o8tB+yck+D3kt5elpRDBt9KjpW6h+vHPy3V0sjDvO0CXyabTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-ia32": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-ia32/-/koffi-win32-ia32-3.1.1.tgz", + "integrity": "sha512-ZRuyYmlGS/rCc966qqs0qREXDW4FRdul7rDF1VgSWHbVmdc196PUgUT+blq/GjZgTwqzeEXtMRgM+cU8krHjvA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "node_modules/@koromix/koffi-win32-x64": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@koromix/koffi-win32-x64/-/koffi-win32-x64-3.1.1.tgz", + "integrity": "sha512-KqHPmvj6QILhNyI/To8QSihHsijeVGIYYPBOUnXEpcnH2LuLbargY4Hd6dDeTN3Z90uUUxN+1FWz1UnhVzFOiA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "funding": { + "url": "https://liberapay.com/Koromix" + } + }, + "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/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dsh-free-search": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/dsh-free-search/-/dsh-free-search-0.8.1.tgz", + "integrity": "sha512-WJ26G+DFrxnrPQXs2IiCH0jApV2SDsAKTAMV5j/KnMqWXME9LPPvCz7wwMAHY7NTkCoZBz8BjIb+3vEAfpZ2JQ==", + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "^3.18.4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-client-ui-commands": "^0.1.7-rc.1 || ^0.2.0-rc.1", + "@deepseek-ai/dsh-client-ui-plugin-manager": "^0.1.7-rc.1 || ^0.2.0-rc.1", + "@deepseek-ai/dsh-client-ui-renderer": "^0.1.7-rc.1 || ^0.2.0-rc.1", + "@deepseek-ai/dsh-settings": "^0.1.7-rc.1 || ^0.2.0-rc.1", + "@deepseek-ai/dsh-tools": "^0.1.7-rc.1 || ^0.2.0-rc.1", + "@deepseek-ai/schemastery": "^3.18.4", + "react": "^18.2.0" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis": { + "optional": true + }, + "@deepseek-ai/dsh-client-ui-commands": { + "optional": true + }, + "@deepseek-ai/dsh-client-ui-plugin-manager": { + "optional": true + }, + "@deepseek-ai/dsh-client-ui-renderer": { + "optional": true + }, + "@deepseek-ai/dsh-settings": { + "optional": true + }, + "@deepseek-ai/dsh-tools": { + "optional": true + }, + "@deepseek-ai/schemastery": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "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/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT", + "peer": true + }, "node_modules/fast-uri": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", @@ -1151,6 +2113,33 @@ "license": "MIT", "peer": true }, + "node_modules/koffi": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/koffi/-/koffi-3.1.1.tgz", + "integrity": "sha512-mRX6AMeeKCxSOeOopqAcLAl5jcNvge7NAG8l7rF/8gGJATI0tdHFYjteIdE0mGOtWdsrJOij+PjnP8Q9c1gwgA==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "url": "https://liberapay.com/Koromix" + }, + "optionalDependencies": { + "@koromix/koffi-darwin-arm64": "3.1.1", + "@koromix/koffi-darwin-x64": "3.1.1", + "@koromix/koffi-freebsd-arm64": "3.1.1", + "@koromix/koffi-freebsd-ia32": "3.1.1", + "@koromix/koffi-freebsd-x64": "3.1.1", + "@koromix/koffi-linux-arm64": "3.1.1", + "@koromix/koffi-linux-ia32": "3.1.1", + "@koromix/koffi-linux-loong64": "3.1.1", + "@koromix/koffi-linux-riscv64": "3.1.1", + "@koromix/koffi-linux-x64": "3.1.1", + "@koromix/koffi-openbsd-ia32": "3.1.1", + "@koromix/koffi-openbsd-x64": "3.1.1", + "@koromix/koffi-win32-arm64": "3.1.1", + "@koromix/koffi-win32-ia32": "3.1.1", + "@koromix/koffi-win32-x64": "3.1.1" + } + }, "node_modules/node-addon-native-custom-loader": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/node-addon-native-custom-loader/-/node-addon-native-custom-loader-0.1.7.tgz", @@ -1365,6 +2354,19 @@ "node": ">=20" } }, + "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==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -1390,7 +2392,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" }, @@ -1398,6 +2399,62 @@ "node": ">=10" } }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.23.15", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", diff --git a/integrations/yoagent-rutis/plugins/dsh/package.json b/integrations/yoagent-rutis/plugins/dsh/package.json index 2a27db9..5595a72 100644 --- a/integrations/yoagent-rutis/plugins/dsh/package.json +++ b/integrations/yoagent-rutis/plugins/dsh/package.json @@ -10,7 +10,14 @@ "@arcships/rutis": "0.9.1", "@arcships/rutis-runtime": "0.9.1", "@deepseek-ai/cordis": "4.0.4", + "@deepseek-ai/dsh-attachment-local": "0.2.0-rc.2", + "@deepseek-ai/dsh-credentials-local": "0.2.0-rc.2", + "@deepseek-ai/dsh-fs-local": "0.2.0-rc.2", + "@deepseek-ai/dsh-session-persistence": "0.2.0-rc.2", "@deepseek-ai/dsh-settings": "0.2.0-rc.2", + "@deepseek-ai/dsh-skill": "0.2.0-rc.2", + "@deepseek-ai/dsh-skill-filesystem": "0.2.0-rc.2", + "@deepseek-ai/dsh-storage": "0.2.0-rc.2", "@deepseek-ai/dsh-system-prompt": "0.2.0-rc.2", "@deepseek-ai/dsh-tool-ask-user": "0.2.0-rc.2", "@deepseek-ai/dsh-tools": "0.2.0-rc.2", From 9d68a0356b2b81919f5d81a33c42708d79d0dc46 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Mon, 12 Oct 2026 14:01:43 +0200 Subject: [PATCH 2/4] feat(dsh): DSH's own host services for dsh plugins; matrix on rutis 0.9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Many dsh plugins wait for services of the DSH host. DSH ships the common ones as packages that run headless; plugins/dsh/host-services.json lists them (dsh-fs-local, dsh-attachment-local, dsh-credentials-local, dsh-storage, dsh-session-persistence, dsh-skill, dsh-skill-filesystem — the -local packages provide fs/attachments/credentials themselves). They keep data and read credentials under DSH_HOME, else ~/.dsh, so the DSH runtime gets a home of its own: the probe a temp dir, the frontend examples YOAGENT_DSH_HOME else $TMPDIR/yoagent-dsh-home (new PluginHostBuilder::env and host::rows_from_file). Plugin compatibility, re-measured on rutis 0.9 (CI, plugin-matrix.yml): DSH 3 -> 4 of 20 usable (@dfy-plugins/dsh-visualize); the rest need DSH's app (web UI, llm, agents, settings — settings needs DSH's app boot, not provided). pi unchanged at 12/20. Test: dsh_host_services_start_headless_in_their_own_dsh_home (all six services present inside the runtime, DSH_HOME a temp dir). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 5 + docs/evals/plugin-compatibility.json | 1314 +++++++++++++---- docs/evals/plugin-compatibility.md | 74 +- integrations/yoagent-frontend/CLAUDE.md | 2 +- .../yoagent-frontend/examples/coding_agent.rs | 25 +- .../examples/handoff_agent.rs | 25 +- integrations/yoagent-frontend/src/host.rs | 60 +- integrations/yoagent-rutis/CLAUDE.md | 2 +- integrations/yoagent-rutis/README.md | 21 +- integrations/yoagent-rutis/tests/dsh_test.rs | 59 +- 10 files changed, 1230 insertions(+), 357 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a655580..8f990aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ adheres to [Semantic Versioning](https://semver.org/). - **The example's approval gate** (`confirm-dangerous.ts`, used by `coding_agent` and `handoff_agent`) asks before selected operations that delete, discard or overwrite the owner's files. It tokenizes each command (quotes, escapes, redirections, heredoc bodies skipped) and reads it command by command. It follows `cd`, looks past wrappers (`env`, `timeout`, `nice`, `xargs`, …), reads git's subcommand past its global options, and checks `bash -c` scripts the same way. Anything with a path it cannot know (`$VAR`, `~`, globs, an unfollowable `cd`) asks. It covers `rm`, `git reset --hard`, `git clean`, `git checkout` / `restore` of paths, `git branch -D`, a forced push, `find -delete`, and a write, `mv`, `cp`, `tee` or redirection over an existing file. A guardrail, not a sandbox: targeted edits and what a script does inside are not checked. Before, it caught only `rm -r`, `sudo`, `chmod -R`, `git push --force`, `mkfs` and `dd`. Files the agent created this session, and the scratch folder a host names in `YOAGENT_SCRATCH`, are exempt. An ended session now ends its open questions with their default, where they used to be left open. Steering or a follow-up that arrives as a run ends (too late for that run to read) now starts the next run instead of waiting for whatever run comes next. - Tool cards: a tool result's `details.view` is drawn as a card in the browser (and titles the tool line: in the browser when no UI plugin draws the tool, always in the terminal UI); questions gain `message` and multiple-choice `select` (options sent once each); the `ui` service gains `frontends()`; the terminal UI asks again on an answer that does not fit. +### yoagent-rutis (unreleased) + +- **DSH's own host services for dsh plugins**: `plugins/dsh/host-services.json` lists the DSH packages that provide what many dsh plugins wait for — files, attachments, credentials, storage, session persistence, skills — and run headless; the probe and the frontend examples load them. They keep data and read credentials under `DSH_HOME`, so hosts give the runtime a home of its own (the examples: `YOAGENT_DSH_HOME`, else a temp dir), never the user's `~/.dsh` unless asked. In the [plugin compatibility](docs/evals/plugin-compatibility.md) matrix (re-measured on rutis 0.9) DSH's top 20 go from 3 to 4 usable (`@dfy-plugins/dsh-visualize`); what still blocks the rest is DSH's app — its web UI, model layer, agents and settings. pi unchanged at 12/20. +- yoagent-frontend: `PluginHostBuilder::env(runtime, name, value)` and `host::rows_from_file`. + ## yoagent-rutis 0.3.0 (2026-10-12) yoagent-rutis only; yoagent is unchanged (0.25.2). Requires yoagent 0.25, **rutis 0.9** and **Rust 1.88**. diff --git a/docs/evals/plugin-compatibility.json b/docs/evals/plugin-compatibility.json index 1534e20..9ac3ca4 100644 --- a/docs/evals/plugin-compatibility.json +++ b/docs/evals/plugin-compatibility.json @@ -1,11 +1,11 @@ { "runs": { "pi": { - "date": "2026-10-11", + "date": "2026-10-12", "selection": "top 20" }, "dsh": { - "date": "2026-10-10", + "date": "2026-10-12", "selection": "top 20" } }, @@ -13,7 +13,7 @@ { "name": "pi-mcp-adapter", "version": "5.2.0", - "monthly": 1606843, + "monthly": 1608890, "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent", "ecosystem": "pi", "skills": { @@ -44,11 +44,11 @@ "message": "[pi] no host ui (print mode): service pi-host-ui is not available" }, { - "level": "warn", + "level": "info", "message": "[pi] not available in yoagent, ignored: index.ts: event \"mcp_servers_change\"; index.ts: event \"resources_discover\"; index.ts: event \"session_tree\"; index.ts: command /mcp-adapter; index.ts: command /mcp; index.ts: command /mcp-auth; index.ts: flag --mcp-config" } ], - "stderr": "", + "stderr": "Turned off Pi's built-in MCP so it doesn't run next to pi-mcp-adapter. If you remove the adapter, turn it back on in `pi config` → Built-in.", "resources": { "extensions": 1, "skills": 0, @@ -69,8 +69,8 @@ }, { "name": "billion-context", - "version": "0.1.192", - "monthly": 936104, + "version": "0.1.195", + "monthly": 1072115, "description": "A context-compression plugin — small context windows (a 100K context is enough), 5x fewer tokens, month-long single sessions (billions of tokens), and compression quality — for all agents: pi, OpenCode, Codex, Claude Code, and more. billion-context is all", "ecosystem": "pi", "skills": { @@ -110,8 +110,8 @@ }, { "name": "pi-web-access", - "version": "0.38.0", - "monthly": 693533, + "version": "0.39.0", + "monthly": 720509, "description": "Web search, URL fetching, GitHub repo cloning, PDF extraction, YouTube video understanding, and local video analysis for Pi coding agent. Supports OpenAI, Brave, Parallel, TinyFish, Search1API, Searchinfinity, Querit, Tavily, Firecrawl, Crawl4AI, Jina, SE", "ecosystem": "pi", "skills": { @@ -145,7 +145,7 @@ "message": "[pi] no host ui (print mode): service pi-host-ui is not available" }, { - "level": "warn", + "level": "info", "message": "[pi] not available in yoagent, ignored: index.js: event \"agent_settled\"; index.js: event \"session_tree\"; index.js: command /websearch; index.js: command /curator; index.js: command /google-account; index.js: command /search; index.js: shortcut ctrl+shift+s; index.js: shortcut ctrl+shift+w" } ], @@ -172,7 +172,7 @@ { "name": "pi-subagents", "version": "0.77.0", - "monthly": 622053, + "monthly": 622966, "description": "Pi extension for single-agent delegation and scripted multi-agent workflows", "ecosystem": "pi", "skills": { @@ -208,7 +208,7 @@ "message": "[pi] no host ui (print mode): service pi-host-ui is not available" }, { - "level": "warn", + "level": "info", "message": "[pi] not available in yoagent, ignored: index.js: event \"turn_end\"; index.js: event \"model_select\"; index.js: event \"agent_end\"; index.js: event \"session_before_switch\"; index.js: event \"session_before_fork\"; index.js: event \"session_compact\"; index.js: event \"session_tree\"; index.js: event \"agent_start\"; index.js: event \"message_start\"; index.js: event \"agent_settled\"; index.js: event \"session_before_compact\"; index.js: command /subagents-watchdog; index.js: command /subagents; index.js: command /run; index.js: command /subagent-cost; index.js: command /subagents-doctor; index.js: command /subagents-inspect-rpc; index.js: command /subagents-guide; index.js: command /subagents-refine; index.js: command /subagents-fleet; index.js: command /subagents-detach; index.js: command /subagents-stop; index.js: command /subagents-steer; index.js: command /prompt-workflow; index.js: command /subagents-models; index.js: command /subagents-profiles; index.js: command /subagents-load-profile; index.js: command /subagents-refresh-provider-models; index.js: command /subagents-generate-profiles; index.js: command /subagents-check-profile; index.js: renderers" }, { @@ -304,8 +304,8 @@ }, { "name": "@juicesharp/rpiv-ask-user-question", - "version": "2.12.0", - "monthly": 306198, + "version": "2.13.5", + "monthly": 301013, "description": "Pi extension. A structured questionnaire the model can put to you when it would otherwise guess, with typed options instead of free-form replies.", "ecosystem": "pi", "skills": { @@ -350,7 +350,7 @@ { "name": "pi-mcp-extension", "version": "1.5.1", - "monthly": 270009, + "monthly": 285855, "description": "MCP (Model Context Protocol) client extension for the Pi coding agent — connect Pi to any MCP server", "ecosystem": "pi", "skills": { @@ -388,8 +388,8 @@ }, { "name": "@juicesharp/rpiv-todo", - "version": "2.12.0", - "monthly": 203830, + "version": "2.13.5", + "monthly": 196941, "description": "Pi extension. A todo list for the model, rendered as a live overlay that survives /reload and conversation compaction.", "ecosystem": "pi", "skills": { @@ -420,7 +420,7 @@ "message": "[pi] no host ui (print mode): service pi-host-ui is not available" }, { - "level": "warn", + "level": "info", "message": "[pi] not available in yoagent, ignored: index.ts: event \"session_compact\"; index.ts: event \"session_tree\"; index.ts: event \"tool_execution_end\"; index.ts: event \"agent_start\"; index.ts: command /todos; index.ts: shortcut ctrl+shift+t" } ], @@ -445,7 +445,7 @@ { "name": "openwiki", "version": "0.7.2", - "monthly": 190350, + "monthly": 193787, "description": "A CLI that uses a DeepAgents documentation agent to generate and maintain an OpenWiki for a codebase.", "ecosystem": "pi", "skills": { @@ -537,8 +537,8 @@ }, { "name": "@plannotator/pi-extension", - "version": "0.28.12", - "monthly": 132034, + "version": "0.28.17", + "monthly": 132677, "description": "Plannotator Pi extension - interactive plan review with annotations, annotate agent messages, and review code/PRs", "ecosystem": "pi", "skills": { @@ -567,7 +567,7 @@ "message": "[pi] no host ui (print mode): service pi-host-ui is not available" }, { - "level": "warn", + "level": "info", "message": "[pi] not available in yoagent, ignored: pi-extension: event \"turn_start\"; pi-extension: event \"turn_end\"; pi-extension: event \"message_start\"; pi-extension: event \"message_update\"; pi-extension: event \"tool_execution_start\"; pi-extension: event \"agent_end\"; pi-extension: event \"agent_settled\"; pi-extension: event \"resources_discover\"; pi-extension: event \"session_compact\"; pi-extension: event \"session_tree\"; pi-extension: command /plannotator-plan-mode; pi-extension: command /plannotator-review; pi-extension: command /plannotator-annotate; pi-extension: command /plannotator-last; pi-extension: shortcut ctrl+alt+p; pi-extension: flag --plan" } ], @@ -732,9 +732,9 @@ }, { "name": "@companion-ai/feynman", - "version": "0.5.34", - "monthly": 120059, - "description": "Research-first CLI agent built on Pi and alphaXiv", + "version": "0.5.38", + "monthly": 114542, + "description": "The open source AI research agent.", "ecosystem": "pi", "skills": { "found": 16, @@ -771,11 +771,11 @@ "logs": [ { "level": "debug", - "message": "[pi] no host ui (print mode): service pi-host-ui is not available" + "message": "[pi] loaded research-tools.ts: events session_start, session_shutdown, before_agent_start, tool_result, message_end, before_provider_request; tools alpha_annotate_paper, alpha_list_annotations, hf_dataset_info, hf_repo_files, hf_repo_read_file, feynman_science_database_search" }, { "level": "debug", - "message": "[pi] loaded research-tools.ts: events session_start, session_shutdown, before_agent_start, tool_result, before_provider_request; tools alpha_annotate_paper, alpha_list_annotations, hf_dataset_info, hf_repo_files, hf_repo_read_file, feynman_science_database_search" + "message": "[pi] no host ui (print mode): service pi-host-ui is not available" } ], "stderr": "", @@ -833,120 +833,10 @@ "result": "refused", "detail": "its session_start failed, so the adapter did not load it: anthropic-attribution.js: Cannot read properties of undefined (reading 'getProvider')" }, - { - "name": "pi-lens", - "version": "4.4.3", - "monthly": 108180, - "description": "Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo", - "ecosystem": "pi", - "skills": { - "found": 4, - "loaded": [ - "pi-lens-ast-grep", - "pi-lens-lsp-navigation", - "pi-lens-write-ast-grep-rule", - "pi-lens-write-tree-sitter-rule" - ], - "errors": [] - }, - "rows": [ - { - "id": "pi", - "state": "active", - "error": null - } - ], - "without": [], - "loaded": true, - "tools": [ - "effective_config", - "lens_diagnostics", - "module_report", - "pi_lens_activate_tools", - "project_report", - "read_enclosing", - "read_symbol", - "symbol_search" - ], - "note_chars": 0, - "logs": [ - { - "level": "debug", - "message": "[pi] loaded index.js: events resources_discover, session_start, session_tree, tool_call, tool_result, tool_execution_end, turn_start, agent_end, turn_end, agent_settled, session_shutdown, message_end, context; tools lens_diagnostics, symbol_search, effective_config, project_report, module_report, read_symbol, read_enclosing, pi_lens_activate_tools, ast_grep_search, ast_grep_replace, ast_grep_outline, lsp_navigation, lens_diagnostic_mark" - }, - { - "level": "debug", - "message": "[pi] no host ui (print mode): service pi-host-ui is not available" - }, - { - "level": "warn", - "message": "[pi] not available in yoagent, ignored: index.js: event \"resources_discover\"; index.js: event \"session_tree\"; index.js: event \"tool_execution_end\"; index.js: event \"turn_start\"; index.js: event \"agent_end\"; index.js: event \"turn_end\"; index.js: event \"agent_settled\"; index.js: command /lens-toggle; index.js: command /lens-context-toggle; index.js: command /lens-widget-toggle; index.js: command /lens-tdi; index.js: command /lens-map; index.js: command /lens-health; index.js: command /lens-perf; index.js: command /lens-tools; index.js: command /lens-allow-edit; index.js: flag --no-lens; index.js: flag --no-lsp; index.js: flag --no-autoformat; index.js: flag --immediate-format; index.js: flag --no-autofix; index.js: flag --no-tests; index.js: flag --no-delta; index.js: flag --lens-guard; index.js: flag --lens-checkout-guard; index.js: flag --no-opengrep; index.js: flag --no-read-guard; index.js: flag --no-lens-context; index.js: flag --lens-turn-summary; index.js: flag --lens-actionable-warnings; index.js: flag --lens-actionable-warning-actions; index.js: flag --lens-actionable-warning-autofix; index.js: flag --lens-actionable-warning-all; index.js: flag --lens-compact-tool-line; index.js: flag --lens-compact-lsp-status; index.js: flag --lens-hide-lsp-status; index.js: flag --lens-compact-widget; index.js: flag --no-lazy-tools; index.js: flag --lens-turn-end-madge; index.js: flag --no-knip; index.js: flag --no-jscpd; index.js: flag --no-madge; index.js: flag --no-gitleaks; index.js: flag --no-govulncheck; index.js: flag --no-dead-code; index.js: flag --no-complexity; index.js: flag --no-tool; index.js: renderers" - } - ], - "stderr": "", - "resources": { - "extensions": 1, - "skills": 4, - "prompts": 0, - "themes": 0 - }, - "result": "works, partly", - "detail": "8 tools, tool-call hooks, 4 skills, message_end and context checked each run (a rewrite would stop it)", - "ignored": [ - "event \"resources_discover\"", - "event \"session_tree\"", - "event \"tool_execution_end\"", - "event \"turn_start\"", - "event \"agent_end\"", - "event \"turn_end\"", - "event \"agent_settled\"", - "command /lens-toggle", - "command /lens-context-toggle", - "command /lens-widget-toggle", - "command /lens-tdi", - "command /lens-map", - "command /lens-health", - "command /lens-perf", - "command /lens-tools", - "command /lens-allow-edit", - "flag --no-lens", - "flag --no-lsp", - "flag --no-autoformat", - "flag --immediate-format", - "flag --no-autofix", - "flag --no-tests", - "flag --no-delta", - "flag --lens-guard", - "flag --lens-checkout-guard", - "flag --no-opengrep", - "flag --no-read-guard", - "flag --no-lens-context", - "flag --lens-turn-summary", - "flag --lens-actionable-warnings", - "flag --lens-actionable-warning-actions", - "flag --lens-actionable-warning-autofix", - "flag --lens-actionable-warning-all", - "flag --lens-compact-tool-line", - "flag --lens-compact-lsp-status", - "flag --lens-hide-lsp-status", - "flag --lens-compact-widget", - "flag --no-lazy-tools", - "flag --lens-turn-end-madge", - "flag --no-knip", - "flag --no-jscpd", - "flag --no-madge", - "flag --no-gitleaks", - "flag --no-govulncheck", - "flag --no-dead-code", - "flag --no-complexity", - "flag --no-tool", - "renderers" - ] - }, { "name": "pi-powerline-footer", "version": "0.20.0", - "monthly": 106500, + "monthly": 106751, "description": "Powerline-style status bar extension for pi coding agent", "ecosystem": "pi", "skills": { @@ -975,7 +865,7 @@ "message": "[pi] no host ui (print mode): service pi-host-ui is not available" }, { - "level": "warn", + "level": "info", "message": "[pi] not available in yoagent, ignored: index.ts: event \"user_bash\"; index.ts: event \"model_select\"; index.ts: event \"thinking_level_select\"; index.ts: event \"session_tree\"; index.ts: event \"session_info_changed\"; index.ts: event \"agent_start\"; index.ts: event \"message_update\"; index.ts: event \"message_start\"; index.ts: event \"turn_end\"; index.ts: event \"session_before_compact\"; index.ts: event \"session_compact\"; index.ts: event \"session_compact_failed\"; index.ts: event \"agent_settled\"; index.ts: event \"agent_end\"; index.ts: command /cd; index.ts: command /reply; index.ts: command /queue; index.ts: command /powerline; index.ts: command /stash-history; index.ts: command /bash-mode; index.ts: command /powerline-perf; index.ts: command /bash-reset; index.ts: command /vibe" } ], @@ -1017,7 +907,7 @@ { "name": "context-mode", "version": "1.0.169", - "monthly": 103613, + "monthly": 104211, "description": "MCP plugin that saves 98% of your context window. Works with Claude Code, Gemini CLI, VS Code Copilot, OpenCode, and Codex CLI. Sandboxed code execution, FTS5 knowledge base, and intent-driven search.", "ecosystem": "pi", "skills": { @@ -1055,7 +945,7 @@ "message": "[pi] no host ui (print mode): service pi-host-ui is not available" }, { - "level": "warn", + "level": "info", "message": "[pi] not available in yoagent, ignored: extension.js: event \"before_provider_response\"; extension.js: event \"turn_end\"; extension.js: event \"session_before_compact\"; extension.js: event \"session_compact\"; extension.js: command /ctx-stats; extension.js: command /ctx-doctor" } ], @@ -1077,10 +967,120 @@ "command /ctx-doctor" ] }, + { + "name": "pi-lens", + "version": "4.4.3", + "monthly": 102007, + "description": "Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo", + "ecosystem": "pi", + "skills": { + "found": 4, + "loaded": [ + "pi-lens-ast-grep", + "pi-lens-lsp-navigation", + "pi-lens-write-ast-grep-rule", + "pi-lens-write-tree-sitter-rule" + ], + "errors": [] + }, + "rows": [ + { + "id": "pi", + "state": "active", + "error": null + } + ], + "without": [], + "loaded": true, + "tools": [ + "effective_config", + "lens_diagnostics", + "module_report", + "pi_lens_activate_tools", + "project_report", + "read_enclosing", + "read_symbol", + "symbol_search" + ], + "note_chars": 0, + "logs": [ + { + "level": "debug", + "message": "[pi] loaded index.js: events resources_discover, session_start, session_tree, tool_call, tool_result, tool_execution_end, turn_start, agent_end, turn_end, agent_settled, session_shutdown, message_end, context; tools lens_diagnostics, symbol_search, effective_config, project_report, module_report, read_symbol, read_enclosing, pi_lens_activate_tools, ast_grep_search, ast_grep_replace, ast_grep_outline, lsp_navigation, lens_diagnostic_mark" + }, + { + "level": "debug", + "message": "[pi] no host ui (print mode): service pi-host-ui is not available" + }, + { + "level": "info", + "message": "[pi] not available in yoagent, ignored: index.js: event \"resources_discover\"; index.js: event \"session_tree\"; index.js: event \"tool_execution_end\"; index.js: event \"turn_start\"; index.js: event \"agent_end\"; index.js: event \"turn_end\"; index.js: event \"agent_settled\"; index.js: command /lens-toggle; index.js: command /lens-context-toggle; index.js: command /lens-widget-toggle; index.js: command /lens-tdi; index.js: command /lens-map; index.js: command /lens-health; index.js: command /lens-perf; index.js: command /lens-tools; index.js: command /lens-allow-edit; index.js: flag --no-lens; index.js: flag --no-lsp; index.js: flag --no-autoformat; index.js: flag --immediate-format; index.js: flag --no-autofix; index.js: flag --no-tests; index.js: flag --no-delta; index.js: flag --lens-guard; index.js: flag --lens-checkout-guard; index.js: flag --no-opengrep; index.js: flag --no-read-guard; index.js: flag --no-lens-context; index.js: flag --lens-turn-summary; index.js: flag --lens-actionable-warnings; index.js: flag --lens-actionable-warning-actions; index.js: flag --lens-actionable-warning-autofix; index.js: flag --lens-actionable-warning-all; index.js: flag --lens-compact-tool-line; index.js: flag --lens-compact-lsp-status; index.js: flag --lens-hide-lsp-status; index.js: flag --lens-compact-widget; index.js: flag --no-lazy-tools; index.js: flag --lens-turn-end-madge; index.js: flag --no-knip; index.js: flag --no-jscpd; index.js: flag --no-madge; index.js: flag --no-gitleaks; index.js: flag --no-govulncheck; index.js: flag --no-dead-code; index.js: flag --no-complexity; index.js: flag --no-tool; index.js: renderers" + } + ], + "stderr": "", + "resources": { + "extensions": 1, + "skills": 4, + "prompts": 0, + "themes": 0 + }, + "result": "works, partly", + "detail": "8 tools, tool-call hooks, 4 skills, message_end and context checked each run (a rewrite would stop it)", + "ignored": [ + "event \"resources_discover\"", + "event \"session_tree\"", + "event \"tool_execution_end\"", + "event \"turn_start\"", + "event \"agent_end\"", + "event \"turn_end\"", + "event \"agent_settled\"", + "command /lens-toggle", + "command /lens-context-toggle", + "command /lens-widget-toggle", + "command /lens-tdi", + "command /lens-map", + "command /lens-health", + "command /lens-perf", + "command /lens-tools", + "command /lens-allow-edit", + "flag --no-lens", + "flag --no-lsp", + "flag --no-autoformat", + "flag --immediate-format", + "flag --no-autofix", + "flag --no-tests", + "flag --no-delta", + "flag --lens-guard", + "flag --lens-checkout-guard", + "flag --no-opengrep", + "flag --no-read-guard", + "flag --no-lens-context", + "flag --lens-turn-summary", + "flag --lens-actionable-warnings", + "flag --lens-actionable-warning-actions", + "flag --lens-actionable-warning-autofix", + "flag --lens-actionable-warning-all", + "flag --lens-compact-tool-line", + "flag --lens-compact-lsp-status", + "flag --lens-hide-lsp-status", + "flag --lens-compact-widget", + "flag --no-lazy-tools", + "flag --lens-turn-end-madge", + "flag --no-knip", + "flag --no-jscpd", + "flag --no-madge", + "flag --no-gitleaks", + "flag --no-govulncheck", + "flag --no-dead-code", + "flag --no-complexity", + "flag --no-tool", + "renderers" + ] + }, { "name": "@dietrichgebert/ponytail", "version": "5.1.0", - "monthly": 97104, + "monthly": 97477, "description": "Lazy senior dev mode for AI agents. The best code is the code you never wrote.", "ecosystem": "pi", "skills": { @@ -1120,7 +1120,7 @@ "message": "[pi info] Ponytail loaded: full" }, { - "level": "warn", + "level": "info", "message": "[pi] not available in yoagent, ignored: index.js: event \"session_tree\"; index.js: event \"agent_start\"; index.js: event \"agent_end\"; index.js: command /ponytail; index.js: command /ponytail-review; index.js: command /ponytail-audit; index.js: command /ponytail-gain; index.js: command /ponytail-debt; index.js: command /ponytail-help" } ], @@ -1177,7 +1177,7 @@ "message": "[pi] no host ui (print mode): service pi-host-ui is not available" }, { - "level": "warn", + "level": "info", "message": "[pi] not available in yoagent, ignored: index.js: command /langsmith-tracing" } ], @@ -1197,7 +1197,7 @@ { "name": "@narumitw/pi-usage", "version": "0.65.0", - "monthly": 84271, + "monthly": 84983, "description": "Pi extension that shows current-account usage and DeepSeek API balance for supported providers.", "ecosystem": "pi", "skills": { @@ -1239,8 +1239,8 @@ "dsh": [ { "name": "dshmarket", - "version": "1.66.15", - "monthly": 561135, + "version": "1.66.17", + "monthly": 570846, "description": "Visual plugin market inside DeepSeek Harness — browse, search, and one-click install community plugins. · DSH 可视化插件市场:逛一逛,点一下,装好。", "ecosystem": "dsh", "services": { @@ -1249,42 +1249,111 @@ }, "rows": [ { - "id": "dsh-market", + "id": "dsh-fs", "state": "active", "error": null - } - ], - "tools": [], - "guidance_chars": 0, - "logs": [], - "stderr": "", - "client": true, - "peers": [ - "@deepseek-ai/dsh-settings" - ], - "inject": [], - "importErrors": {}, - "result": "not applicable", - "detail": "starts, but adds no tools or guidance (UI, settings or server features). Its web UI needs DSH's web client." - }, - { + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, + { + "id": "dsh-market", + "state": "active", + "error": null + } + ], + "tools": [], + "guidance_chars": 0, + "logs": [], + "stderr": "", + "client": true, + "peers": [ + "@deepseek-ai/dsh-settings" + ], + "inject": [], + "importErrors": {}, + "result": "not applicable", + "detail": "starts, but adds no tools or guidance (UI, settings or server features). Its web UI needs DSH's web client." + }, + { "name": "@michengai/dsh-skills-manager", - "version": "1.1.13", - "monthly": 201182, + "version": "1.1.17", + "monthly": 202149, "description": "DSH Skills Manager — 在 DeepSeek Harness 中统一加载并安全管理本机 Agent Skills · Load and safely manage local Agent Skills in DSH", "ecosystem": "dsh", "services": { "present": [ + "skills", "tools" ], "missing": [ "webServer", - "webRuntime", - "skills", "sessions" ] }, "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "skills-manager", "state": "active", @@ -1307,19 +1376,19 @@ ], "inject": [ "webServer", - "webRuntime", "skills", "tools", "sessions" ], "importErrors": {}, + "installNote": "installed without npm resolving its peers", "result": "needs a DSH host", - "detail": "waits for services yoagent's DSH runtime does not run: webServer, webRuntime, skills, sessions. Its web UI needs DSH's web client." + "detail": "waits for services yoagent's DSH runtime does not run: webServer, sessions. Its web UI needs DSH's web client. (installed without npm resolving its peers)" }, { "name": "@michengai/dsh-archive-manager", - "version": "1.0.16", - "monthly": 180612, + "version": "1.0.17", + "monthly": 182870, "description": "归档、恢复、收藏和永久删除会话,并诊断损坏的会话日志。 · Archive, restore, favorite, and permanently delete sessions, and diagnose damaged session logs.", "ecosystem": "dsh", "services": { @@ -1327,10 +1396,45 @@ "missing": [] }, "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "ui-workspace-archive-manager", "state": "unresolved", - "error": "cannot load \"@michengai/dsh-archive-manager\": Error: Cannot find package '@deepseek-ai/dsh-workspace' imported from /private/var/folders/3k/_tx8d93x0yd6km99qqlv6qgm0000gn/T/eco/w/dsh/@michengai__dsh-archive-manager/node_modules/@michengai/dsh-archive-manager/lib/workspace.js" + "error": "cannot load \"@michengai/dsh-archive-manager\": Error: Cannot find package '@deepseek-ai/dsh-workspace' imported from /tmp/yoagent-matrix-eqeP5X/dsh/@michengai__dsh-archive-manager/node_modules/@michengai/dsh-archive-manager/lib/workspace.js" } ], "tools": [], @@ -1359,7 +1463,7 @@ ], "inject": [], "importErrors": { - "ui-workspace-archive-manager": "Cannot find package '@deepseek-ai/dsh-workspace' imported from /private/var/folders/3k/_tx8d93x0yd6km99qqlv6qgm0000gn/T/eco/w/dsh/@michengai__dsh-archive-manager/node_modules/@michengai/dsh-archive-manager/lib/workspace.js" + "ui-workspace-archive-manager": "Cannot find package '@deepseek-ai/dsh-workspace' imported from /tmp/yoagent-matrix-eqeP5X/dsh/@michengai__dsh-archive-manager/node_modules/@michengai/dsh-archive-manager/lib/workspace.js" }, "installNote": "installed without its DSH peers (@deepseek-ai/dsh-workspace@0.2.0-rc.2, @deepseek-ai/dsh-spill-local@0.2.0-rc.2, @deepseek-ai/dsh-client-store@0.2.0-rc.2, @deepseek-ai/dsh-client-locale@0.2.0-rc.2, @deepseek-ai/dsh-client-runtime@0.2.0-rc.2, @deepseek-ai/dsh-host-webserver@0.2.0-rc.2, @deepseek-ai/dsh-storage-domain@0.2.0-rc.2, @deepseek-ai/dsh-typert-registry@0.2.0-rc.2, @deepseek-ai/dsh-client-connection@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-sidebar@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-workspace@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-conversation@0.2.0-rc.2, @deepseek-ai/dsh-session-format-catalog@0.2.0-rc.2, @deepseek-ai/dsh-session-projection-cache@0.2.0-rc.2)", "result": "needs a DSH host", @@ -1367,8 +1471,8 @@ }, { "name": "dsh-whale-widget", - "version": "0.3.18", - "monthly": 146796, + "version": "0.4.2", + "monthly": 154055, "description": "DSH Web 界面右下角的 DeepSeek 余额小鲸鱼挂件:余额/今日已用/峰谷定价、自定义泡泡点击序列(文本/余额/今日/峰谷/图片/随机语句与并列加权选择)、逐行样式与字体、悬浮快捷编辑、音效与每轮消耗、自定义角色/动图/音效、吸附与翻转自定义", "ecosystem": "dsh", "services": { @@ -1376,6 +1480,41 @@ "missing": [] }, "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "dsh-whale-widget", "state": "active", @@ -1407,6 +1546,41 @@ ] }, "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "model-proxy", "state": "active", @@ -1431,8 +1605,8 @@ }, { "name": "dsh-cost-meter", - "version": "1.8.20", - "monthly": 108950, + "version": "1.8.22", + "monthly": 108645, "description": "DeepSeek Harness 会话费用统计插件:本会话成本、当日费用、历史记录与官方价格同步,支持多厂商多模型价格计费(内置 170+ 模型 ID 价格目录与自动匹配)、主流 Coding Plan 订阅额度查询与显示(11 家,含小米 MiMo Token Plan 控制台 Cookie 查询、SCNet Token Plan 本地 Credits 计量与火山方舟 Volcano Ark Coding Plan AK/SK 签名查询)、自定义 Provider 余额查询(可配任意 HTTP 端点)与", "ecosystem": "dsh", "services": { @@ -1440,6 +1614,41 @@ "missing": [] }, "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "cost-meter", "state": "active", @@ -1462,19 +1671,55 @@ }, { "name": "@xmanrui/dsh-im", - "version": "4.38.0", - "monthly": 62925, + "version": "4.38.1", + "monthly": 62286, "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.", "ecosystem": "dsh", "services": { - "present": [], + "present": [ + "credentials" + ], "missing": [ "connection", - "credentials", "typertGateway" ] }, "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "xmanrui-dsh-im", "state": "active", @@ -1494,11 +1739,11 @@ ], "importErrors": {}, "result": "needs a DSH host", - "detail": "waits for services yoagent's DSH runtime does not run: connection, credentials, typertGateway. Its web UI needs DSH's web client." + "detail": "waits for services yoagent's DSH runtime does not run: connection, typertGateway. Its web UI needs DSH's web client." }, { "name": "@nanmicoder/dsh-agent-teams", - "version": "0.1.22", + "version": "0.1.23", "monthly": 55140, "description": "AgentTeams for DeepSeek Harness: multi-agent team collaboration (captain, members, tasks with dependencies, messaging) driven by natural language, with a tree monitor in the web GUI", "ecosystem": "dsh", @@ -1514,6 +1759,41 @@ ] }, "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "agent-teams", "state": "active", @@ -1567,20 +1847,55 @@ "ecosystem": "dsh", "services": { "present": [ - "tools" - ], - "missing": [ + "tools", "attachments", "credentials", "fs", "skills", - "sessionPersistence", + "sessionPersistence" + ], + "missing": [ "settings" ] }, "rows": [ { - "id": "image-generation", + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, + { + "id": "image-generation", "state": "active", "error": null } @@ -1617,12 +1932,12 @@ "importErrors": {}, "installNote": "installed without npm resolving its peers", "result": "needs a DSH host", - "detail": "waits for services yoagent's DSH runtime does not run: attachments, credentials, fs, skills, sessionPersistence, settings. Its web UI needs DSH's web client. (installed without npm resolving its peers)" + "detail": "waits for services yoagent's DSH runtime does not run: settings. Its web UI needs DSH's web client. (installed without npm resolving its peers)" }, { "name": "dsh-univer-office", "version": "0.3.7", - "monthly": 49557, + "monthly": 50677, "description": "DSH × Univer integration with a bundled collaboration Gateway and Viewer: inline previews, live floating Worktree windows, and session-end review actions in DeepSeek Harness.", "ecosystem": "dsh", "services": { @@ -1630,6 +1945,41 @@ "missing": [] }, "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "univer", "state": "active", @@ -1647,6 +1997,7 @@ "univer_new", "univer_print_pdf", "univer_resources", + "univer_screenshot", "univer_status", "univer_unit", "univer_worktree" @@ -1667,15 +2018,15 @@ "inject": [], "importErrors": {}, "result": "works, partly", - "detail": "13 tools", + "detail": "14 tools", "ignored": [ "its web UI (DSH's web client)" ] }, { "name": "dsh-mnemon", - "version": "0.5.27", - "monthly": 44342, + "version": "0.5.28", + "monthly": 45540, "description": "Composable, view-based memory for DeepSeek Harness. Pluggable sources and strategies, with layered memory out of the box.", "ecosystem": "dsh", "services": { @@ -1685,6 +2036,41 @@ ] }, "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "mnemon-bundle", "state": "disabled", @@ -1708,10 +2094,168 @@ "result": "needs a DSH host", "detail": "waits for services yoagent's DSH runtime does not run: loader. Its web UI needs DSH's web client." }, + { + "name": "dsh-plugin-wallpaper-engine", + "version": "1.3.2", + "monthly": 43299, + "description": "Wallpaper Engine backgrounds for the DSH web GUI: render your local Wallpaper Engine wallpapers behind the chat — Scene and Web in real time through the bundled WebWallGL engine, Video played directly — with iOS-style liquid glass, adjustable blur/scrim/b", + "ecosystem": "dsh", + "services": { + "present": [], + "missing": [ + "webServer" + ] + }, + "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, + { + "id": "wallpaper-engine", + "state": "active", + "error": null + } + ], + "tools": [], + "guidance_chars": 0, + "logs": [], + "stderr": "", + "client": true, + "peers": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-slots", + "@deepseek-ai/dsh-host-webserver" + ], + "inject": [ + "webServer" + ], + "importErrors": {}, + "installNote": "installed without its DSH peers (@deepseek-ai/dsh-client-runtime@0.2.0-rc.2, @deepseek-ai/dsh-host-webserver@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-slots@0.2.0-rc.2)", + "result": "needs a DSH host", + "detail": "waits for services yoagent's DSH runtime does not run: webServer. Its web UI needs DSH's web client. (installed without its DSH peers (@deepseek-ai/dsh-client-runtime@0.2.0-rc.2, @deepseek-ai/dsh-host-webserver@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-slots@0.2.0-rc.2))" + }, + { + "name": "dsh-free-search", + "version": "0.8.9", + "monthly": 42419, + "description": "Free web search for DeepSeek Harness: 24 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable/Firecrawl/Parallel keyless; Zhihu global/site via MCP and Perplexity/SerpBase/DeepSeek/You.com/Baidu/Kimi/Aliyun/Doubao with key; OpenAI/Gemini/Claude ", + "ecosystem": "dsh", + "services": { + "present": [ + "web" + ], + "missing": [] + }, + "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, + { + "id": "web-search-free", + "state": "active", + "error": null + } + ], + "tools": [ + "advanced_search", + "cache_search", + "domain_search", + "free_search_test", + "multi_search", + "platform_search", + "research", + "video_search", + "weather" + ], + "guidance_chars": 1509, + "logs": [], + "stderr": "", + "client": true, + "peers": [ + "@deepseek-ai/dsh-client-ui-commands", + "@deepseek-ai/dsh-client-ui-plugin-manager", + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-settings", + "@deepseek-ai/dsh-tools" + ], + "inject": [ + "web" + ], + "importErrors": {}, + "result": "works, partly", + "detail": "9 tools, prompt guidance", + "ignored": [ + "its web UI (DSH's web client)" + ] + }, { "name": "@liustack/modsearch", "version": "5.10.6", - "monthly": 43116, + "monthly": 42230, "description": "The strongest free web search plugin for coding agents. Plug-in web search, X search, and page fetch for models without native web access. Free out of the box: no signup, no API key (Firecrawl keyless, 1,000 free credits/month)", "ecosystem": "dsh", "services": { @@ -1722,6 +2266,41 @@ "missing": [] }, "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "modsearch", "state": "failed", @@ -1749,16 +2328,52 @@ "description": "吃白饭的蓝色大肥鱼,赖在 DeepSeek Harness 里不走了。平时爱玩魔方、爱吃各种零食、偶尔打个盹,闲不住就在桌面上溜达两圈,被你拖来甩去也照旧赖着;你忙起来它跟着忙前忙后,也会帮你打理余额钱包,闲下来爱碎碎念,还乐意陪你唠两句。", "ecosystem": "dsh", "services": { - "present": [], + "present": [ + "credentials" + ], "missing": [ "webServer", "agentDefaultModel", - "credentials", "llm", "commands" ] }, "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "dsh-pet", "state": "active", @@ -1790,7 +2405,7 @@ ], "importErrors": {}, "result": "needs a DSH host", - "detail": "waits for services yoagent's DSH runtime does not run: webServer, agentDefaultModel, credentials, llm, commands. Its web UI needs DSH's web client." + "detail": "waits for services yoagent's DSH runtime does not run: webServer, agentDefaultModel, llm, commands. Its web UI needs DSH's web client." }, { "name": "@dfy-plugins/dsh-bundle", @@ -1800,23 +2415,58 @@ "ecosystem": "dsh", "services": { "present": [ - "tools" + "attachments", + "tools", + "credentials", + "fs", + "skills", + "sessionPersistence" ], "missing": [ "webServer", "settings", "workspaceRegistry", "llm", - "attachments", "agents", - "credentials", - "fs", - "skills", - "sessionPersistence", "agentPresets" ] }, "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "wallpaper", "state": "active", @@ -1858,7 +2508,9 @@ "error": null } ], - "tools": [], + "tools": [ + "dfy_visualize_render" + ], "guidance_chars": 0, "logs": [], "stderr": "", @@ -1880,107 +2532,54 @@ ], "importErrors": {}, "result": "needs a DSH host", - "detail": "waits for services yoagent's DSH runtime does not run: webServer, settings, workspaceRegistry, llm, attachments, agents, credentials, fs, skills, sessionPersistence, agentPresets." + "detail": "waits for services yoagent's DSH runtime does not run: webServer, settings, workspaceRegistry, llm, agents, agentPresets." }, { - "name": "dsh-plugin-wallpaper-engine", - "version": "1.3.1", - "monthly": 41148, - "description": "Wallpaper Engine backgrounds for the DSH web GUI: render your local Wallpaper Engine wallpapers behind the chat — Scene and Web in real time through the bundled WebWallGL engine, Video played directly — with iOS-style liquid glass, adjustable blur/scrim/b", + "name": "@roarpeng/graphflow", + "version": "2.3.0", + "monthly": 36397, + "description": "Local-first memory & context harness for coding agents", "ecosystem": "dsh", "services": { "present": [], - "missing": [ - "webServer" - ] + "missing": [] }, "rows": [ { - "id": "wallpaper-engine", + "id": "dsh-fs", "state": "active", "error": null - } - ], - "tools": [], - "guidance_chars": 0, - "logs": [], - "stderr": "", - "client": true, - "peers": [ - "@deepseek-ai/dsh-client-runtime", - "@deepseek-ai/dsh-client-ui-slots", - "@deepseek-ai/dsh-host-webserver" - ], - "inject": [ - "webServer" - ], - "importErrors": {}, - "installNote": "installed without its DSH peers (@deepseek-ai/dsh-client-runtime@0.2.0-rc.2, @deepseek-ai/dsh-host-webserver@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-slots@0.2.0-rc.2)", - "result": "needs a DSH host", - "detail": "waits for services yoagent's DSH runtime does not run: webServer. Its web UI needs DSH's web client. (installed without its DSH peers (@deepseek-ai/dsh-client-runtime@0.2.0-rc.2, @deepseek-ai/dsh-host-webserver@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-slots@0.2.0-rc.2))" - }, - { - "name": "dsh-free-search", - "version": "0.8.5", - "monthly": 40197, - "description": "Free web search for DeepSeek Harness: 24 engines (Bing/DuckDuckGo/AnySearch/SearXNG/Exa/Tavily/Keenable/Firecrawl/Parallel keyless; Zhihu global/site via MCP and Perplexity/SerpBase/DeepSeek/You.com/Baidu/Kimi/Aliyun/Doubao with key; OpenAI/Gemini/Claude ", - "ecosystem": "dsh", - "services": { - "present": [ - "web" - ], - "missing": [] - }, - "rows": [ + }, { - "id": "web-search-free", + "id": "dsh-attachments", "state": "active", "error": null - } - ], - "tools": [ - "advanced_search", - "cache_search", - "domain_search", - "free_search_test", - "multi_search", - "platform_search", - "research", - "video_search", - "weather" - ], - "guidance_chars": 1509, - "logs": [], - "stderr": "", - "client": true, - "peers": [ - "@deepseek-ai/dsh-client-ui-commands", - "@deepseek-ai/dsh-client-ui-plugin-manager", - "@deepseek-ai/dsh-client-ui-renderer", - "@deepseek-ai/dsh-settings", - "@deepseek-ai/dsh-tools" - ], - "inject": [ - "web" - ], - "importErrors": {}, - "result": "works, partly", - "detail": "9 tools, prompt guidance", - "ignored": [ - "its web UI (DSH's web client)" - ] - }, - { - "name": "@roarpeng/graphflow", - "version": "2.2.0", - "monthly": 34657, - "description": "Local-first memory & context harness for coding agents", - "ecosystem": "dsh", - "services": { - "present": [], - "missing": [] - }, - "rows": [ + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "mcp-graphflow", "state": "unresolved", @@ -2000,15 +2599,15 @@ "peers": [], "inject": [], "importErrors": { - "mcp-graphflow": "Cannot find module '@deepseek-ai/dsh-mcp-client'\nRequire stack:\n- /var/folders/3k/_tx8d93x0yd6km99qqlv6qgm0000gn/T/eco/w/dsh/@roarpeng__graphflow/package.json" + "mcp-graphflow": "Cannot find module '@deepseek-ai/dsh-mcp-client'\nRequire stack:\n- /tmp/yoagent-matrix-eqeP5X/dsh/@roarpeng__graphflow/package.json" }, "result": "needs a DSH host", "detail": "loads DSH packages an agent library does not ship: @deepseek-ai/dsh-mcp-client. Its web UI needs DSH's web client." }, { "name": "dsh-dream-skin", - "version": "10.10.0", - "monthly": 33804, + "version": "10.12.1", + "monthly": 35393, "description": "DeepSeek Harness 换肤插件(Dream Skin for DSH):8 套 iOS / Linear 式清透冷调的高质感主题 + 弥散光壁纸 + 每皮肤智能背景 + 强调色 + 主题包分享,把换肤做成材质与配色克制的高级感工艺,而非贴图。Works in native DSH Web and third-party desktop clients (DSH Desktop): 8 original premium themes, glass materials, wallpaper 2.0", "ecosystem": "dsh", "services": { @@ -2019,6 +2618,41 @@ ] }, "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "dream-skin", "state": "active", @@ -2055,22 +2689,58 @@ "ecosystem": "dsh", "services": { "present": [ - "tools" - ], - "missing": [ + "tools", "fs", "skills", "sessionPersistence" - ] + ], + "missing": [] }, "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "visualize", "state": "active", "error": null } ], - "tools": [], + "tools": [ + "dfy_visualize_render" + ], "guidance_chars": 0, "logs": [], "stderr": "", @@ -2092,8 +2762,11 @@ ], "importErrors": {}, "installNote": "installed without npm resolving its peers", - "result": "needs a DSH host", - "detail": "waits for services yoagent's DSH runtime does not run: fs, skills, sessionPersistence. Its web UI needs DSH's web client. (installed without npm resolving its peers)" + "result": "works, partly", + "detail": "1 tool (installed without npm resolving its peers)", + "ignored": [ + "its web UI (DSH's web client)" + ] }, { "name": "dsh-find-plugin", @@ -2108,6 +2781,41 @@ "missing": [] }, "rows": [ + { + "id": "dsh-fs", + "state": "active", + "error": null + }, + { + "id": "dsh-attachments", + "state": "active", + "error": null + }, + { + "id": "dsh-credentials", + "state": "active", + "error": null + }, + { + "id": "dsh-storage", + "state": "active", + "error": null + }, + { + "id": "dsh-session-persistence", + "state": "active", + "error": null + }, + { + "id": "dsh-skills", + "state": "active", + "error": null + }, + { + "id": "dsh-skill-filesystem", + "state": "active", + "error": null + }, { "id": "find-dsh-plugin", "state": "active", diff --git a/docs/evals/plugin-compatibility.md b/docs/evals/plugin-compatibility.md index 6b7b2ca..bb01873 100644 --- a/docs/evals/plugin-compatibility.md +++ b/docs/evals/plugin-compatibility.md @@ -2,7 +2,7 @@ How the most-downloaded third-party plugins of two agent ecosystems load in a yoagent agent, unchanged, through yoagent-rutis's adapters. Generated by -`scripts/ecosystem-matrix.mjs` on 2026-10-11 and 2026-10-10; raw results in +`scripts/ecosystem-matrix.mjs` on 2026-10-12; raw results in [`plugin-compatibility.json`](plugin-compatibility.json). **What was checked.** Each package is installed on its own from npm and @@ -13,7 +13,7 @@ every tool was exercised. **In short.** - **pi:** 12 of 20 give an agent run tools, tool-call or input hooks, or prompt additions (3 fully). 5 are refused because they hook what yoagent cannot enforce, or their startup needs pi app internals; 3 register nothing usable as installed. Their skills: all 118 (in 7 packages) load with yoagent's skills loader. -- **DSH:** 3 of 20 give an agent tools or prompt guidance. 13 need DSH's own app (its sessions, web server, model layer); 3 add nothing an agent uses (UI or settings). DSH's popular plugins mostly extend the DSH app rather than add agent tools. +- **DSH:** 4 of 20 give an agent tools or prompt guidance. 12 need DSH's own app (its sessions, web server, model layer); 3 add nothing an agent uses (UI or settings). DSH's popular plugins mostly extend the DSH app rather than add agent tools. **Results.** - **works**: loads; everything it registers maps to yoagent. @@ -42,54 +42,54 @@ every tool was exercised. ## pi packages -The 20 most-downloaded packages (by monthly downloads, among the first 1000 results of npm's search) with the `pi-package` keyword, through the pi adapter (pi 1.1.0), on 2026-10-11. 9 works, partly · 5 refused · 3 nothing to use · 3 works. +The 20 most-downloaded packages (by monthly downloads, among the first 1000 results of npm's search) with the `pi-package` keyword, through the pi adapter (pi 1.1.0), on 2026-10-12. 9 works, partly · 5 refused · 3 nothing to use · 3 works. | # | Package | Downloads / month | Result | What yoagent gets | |---|---|---:|---|---| -| 1 | [pi-mcp-adapter](https://www.npmjs.com/package/pi-mcp-adapter) 5.2.0 | 1,606,843 | works, partly | 1 tool, tool-call hooks, input hooks
ignored: event "mcp_servers_change"; event "resources_discover"; event "session_tree"; command /mcp-adapter; command /mcp; command /mcp-auth; flag --mcp-config | -| 2 | [billion-context](https://www.npmjs.com/package/billion-context) 0.1.192 | 936,104 | refused | pi extensions handle events yoagent never fires, which would decide or rewrite what the agent does: pi-native.js: event "before_provider_headers"; pi-native.js: event "before_provider_request" (list an event in allowUnmapped to accept that it goes unenforced) | -| 3 | [pi-web-access](https://www.npmjs.com/package/pi-web-access) 0.38.0 | 693,533 | works, partly | 4 tools, tool-call hooks
ignored: event "agent_settled"; event "session_tree"; command /websearch; command /curator; command /google-account; command /search; shortcut ctrl+shift+s; shortcut ctrl+shift+w | -| 4 | [pi-subagents](https://www.npmjs.com/package/pi-subagents) 0.77.0 | 622,053 | works, partly | 3 tools, tool-call hooks, input hooks, 2 skills
ignored: event "turn_end"; event "model_select"; event "agent_end"; event "session_before_switch"; event "session_before_fork"; event "session_compact"; event "session_tree"; event "agent_start"; event "message_start"; event "agent_settled"; event "session_before_compact"; command /subagents-watchdog; comman | +| 1 | [pi-mcp-adapter](https://www.npmjs.com/package/pi-mcp-adapter) 5.2.0 | 1,608,890 | works, partly | 1 tool, tool-call hooks, input hooks
ignored: event "mcp_servers_change"; event "resources_discover"; event "session_tree"; command /mcp-adapter; command /mcp; command /mcp-auth; flag --mcp-config | +| 2 | [billion-context](https://www.npmjs.com/package/billion-context) 0.1.195 | 1,072,115 | refused | pi extensions handle events yoagent never fires, which would decide or rewrite what the agent does: pi-native.js: event "before_provider_headers"; pi-native.js: event "before_provider_request" (list an event in allowUnmapped to accept that it goes unenforced) | +| 3 | [pi-web-access](https://www.npmjs.com/package/pi-web-access) 0.39.0 | 720,509 | works, partly | 4 tools, tool-call hooks
ignored: event "agent_settled"; event "session_tree"; command /websearch; command /curator; command /google-account; command /search; shortcut ctrl+shift+s; shortcut ctrl+shift+w | +| 4 | [pi-subagents](https://www.npmjs.com/package/pi-subagents) 0.77.0 | 622,966 | works, partly | 3 tools, tool-call hooks, input hooks, 2 skills
ignored: event "turn_end"; event "model_select"; event "agent_end"; event "session_before_switch"; event "session_before_fork"; event "session_compact"; event "session_tree"; event "agent_start"; event "message_start"; event "agent_settled"; event "session_before_compact"; command /subagents-watchdog; comman | | 5 | [@langfuse/pi-observability-plugin](https://www.npmjs.com/package/@langfuse/pi-observability-plugin) 0.1.2 | 322,140 | nothing to use | loads but registers only session_start as installed (it may need configuration first) | -| 6 | [@juicesharp/rpiv-ask-user-question](https://www.npmjs.com/package/@juicesharp/rpiv-ask-user-question) 2.12.0 | 306,198 | works | 1 tool | -| 7 | [pi-mcp-extension](https://www.npmjs.com/package/pi-mcp-extension) 1.5.1 | 270,009 | nothing to use | loads but registers nothing as installed (it may need configuration first) | -| 8 | [@juicesharp/rpiv-todo](https://www.npmjs.com/package/@juicesharp/rpiv-todo) 2.12.0 | 203,830 | works, partly | 1 tool
ignored: event "session_compact"; event "session_tree"; event "tool_execution_end"; event "agent_start"; command /todos; shortcut ctrl+shift+t | -| 9 | [openwiki](https://www.npmjs.com/package/openwiki) 0.7.2 | 190,350 | works | 6 tools, 1 skill | +| 6 | [@juicesharp/rpiv-ask-user-question](https://www.npmjs.com/package/@juicesharp/rpiv-ask-user-question) 2.13.5 | 301,013 | works | 1 tool | +| 7 | [pi-mcp-extension](https://www.npmjs.com/package/pi-mcp-extension) 1.5.1 | 285,855 | nothing to use | loads but registers nothing as installed (it may need configuration first) | +| 8 | [@juicesharp/rpiv-todo](https://www.npmjs.com/package/@juicesharp/rpiv-todo) 2.13.5 | 196,941 | works, partly | 1 tool
ignored: event "session_compact"; event "session_tree"; event "tool_execution_end"; event "agent_start"; command /todos; shortcut ctrl+shift+t | +| 9 | [openwiki](https://www.npmjs.com/package/openwiki) 0.7.2 | 193,787 | works | 6 tools, 1 skill | | 10 | [pi-goal-x](https://www.npmjs.com/package/pi-goal-x) 0.32.3 | 141,230 | refused | pi extensions handle events yoagent never fires, which would decide or rewrite what the agent does: goal.ts: event "before_provider_request" (list an event in allowUnmapped to accept that it goes unenforced) | -| 11 | [@plannotator/pi-extension](https://www.npmjs.com/package/@plannotator/pi-extension) 0.28.12 | 132,034 | works, partly | tool-call hooks
ignored: event "turn_start"; event "turn_end"; event "message_start"; event "message_update"; event "tool_execution_start"; event "agent_end"; event "agent_settled"; event "resources_discover"; event "session_compact"; event "session_tree"; command /plannotator-plan-mode; command /plannotator-review; command | +| 11 | [@plannotator/pi-extension](https://www.npmjs.com/package/@plannotator/pi-extension) 0.28.17 | 132,677 | works, partly | tool-call hooks
ignored: event "turn_start"; event "turn_end"; event "message_start"; event "message_update"; event "tool_execution_start"; event "agent_end"; event "agent_settled"; event "resources_discover"; event "session_compact"; event "session_tree"; command /plannotator-plan-mode; command /plannotator-review; command | | 12 | [bigpowers](https://www.npmjs.com/package/bigpowers) 2.88.9 | 130,483 | works | 1 tool, tool-call hooks, 81 skills | -| 13 | [@companion-ai/feynman](https://www.npmjs.com/package/@companion-ai/feynman) 0.5.34 | 120,059 | refused | pi extensions handle events yoagent never fires, which would decide or rewrite what the agent does: research-tools.ts: event "before_provider_request" (list an event in allowUnmapped to accept that it goes unenforced) Its 16 skills still load with yoagent's skills loader. | +| 13 | [@companion-ai/feynman](https://www.npmjs.com/package/@companion-ai/feynman) 0.5.38 | 114,542 | refused | pi extensions handle events yoagent never fires, which would decide or rewrite what the agent does: research-tools.ts: event "before_provider_request" (list an event in allowUnmapped to accept that it goes unenforced) Its 16 skills still load with yoagent's skills loader. | | 14 | [pi-background-tasks](https://www.npmjs.com/package/pi-background-tasks) 2.6.9 | 108,986 | refused | its session_start failed, so the adapter did not load it: anthropic-attribution.js: Cannot read properties of undefined (reading 'getProvider') | -| 15 | [pi-lens](https://www.npmjs.com/package/pi-lens) 4.4.3 | 108,180 | works, partly | 8 tools, tool-call hooks, 4 skills, message_end and context checked each run (a rewrite would stop it)
ignored: event "resources_discover"; event "session_tree"; event "tool_execution_end"; event "turn_start"; event "agent_end"; event "turn_end"; event "agent_settled"; command /lens-toggle; command /lens-context-toggle; command /lens-widget-toggle; command /lens-tdi; command /lens-map; command /lens-health; c | -| 16 | [pi-powerline-footer](https://www.npmjs.com/package/pi-powerline-footer) 0.20.0 | 106,500 | works, partly | tool-call hooks, input hooks, message_end checked each run (a rewrite would stop it)
ignored: event "user_bash"; event "model_select"; event "thinking_level_select"; event "session_tree"; event "session_info_changed"; event "agent_start"; event "message_update"; event "message_start"; event "turn_end"; event "session_before_compact"; event "session_compact"; event "session_compact_failed"; e | -| 17 | [context-mode](https://www.npmjs.com/package/context-mode) 1.0.169 | 103,613 | works, partly | tool-call hooks, 8 skills, context checked each run (a rewrite would stop it)
ignored: event "before_provider_response"; event "turn_end"; event "session_before_compact"; event "session_compact"; command /ctx-stats; command /ctx-doctor | -| 18 | [@dietrichgebert/ponytail](https://www.npmjs.com/package/@dietrichgebert/ponytail) 5.1.0 | 97,104 | works, partly | input hooks, prompt additions, 6 skills
ignored: event "session_tree"; event "agent_start"; event "agent_end"; command /ponytail; command /ponytail-review; command /ponytail-audit; command /ponytail-gain; command /ponytail-debt; command /ponytail-help | +| 15 | [pi-powerline-footer](https://www.npmjs.com/package/pi-powerline-footer) 0.20.0 | 106,751 | works, partly | tool-call hooks, input hooks, message_end checked each run (a rewrite would stop it)
ignored: event "user_bash"; event "model_select"; event "thinking_level_select"; event "session_tree"; event "session_info_changed"; event "agent_start"; event "message_update"; event "message_start"; event "turn_end"; event "session_before_compact"; event "session_compact"; event "session_compact_failed"; e | +| 16 | [context-mode](https://www.npmjs.com/package/context-mode) 1.0.169 | 104,211 | works, partly | tool-call hooks, 8 skills, context checked each run (a rewrite would stop it)
ignored: event "before_provider_response"; event "turn_end"; event "session_before_compact"; event "session_compact"; command /ctx-stats; command /ctx-doctor | +| 17 | [pi-lens](https://www.npmjs.com/package/pi-lens) 4.4.3 | 102,007 | works, partly | 8 tools, tool-call hooks, 4 skills, message_end and context checked each run (a rewrite would stop it)
ignored: event "resources_discover"; event "session_tree"; event "tool_execution_end"; event "turn_start"; event "agent_end"; event "turn_end"; event "agent_settled"; command /lens-toggle; command /lens-context-toggle; command /lens-widget-toggle; command /lens-tdi; command /lens-map; command /lens-health; c | +| 18 | [@dietrichgebert/ponytail](https://www.npmjs.com/package/@dietrichgebert/ponytail) 5.1.0 | 97,477 | works, partly | input hooks, prompt additions, 6 skills
ignored: event "session_tree"; event "agent_start"; event "agent_end"; command /ponytail; command /ponytail-review; command /ponytail-audit; command /ponytail-gain; command /ponytail-debt; command /ponytail-help | | 19 | [@langchain/langsmith-pi-extension](https://www.npmjs.com/package/@langchain/langsmith-pi-extension) 0.2.1 | 91,431 | nothing to use | loads; everything it registers is ignored here
ignored: command /langsmith-tracing | -| 20 | [@narumitw/pi-usage](https://www.npmjs.com/package/@narumitw/pi-usage) 0.65.0 | 84,271 | refused | pi extensions handle events yoagent never fires, which would decide or rewrite what the agent does: index.ts: event "before_provider_request" (list an event in allowUnmapped to accept that it goes unenforced) | +| 20 | [@narumitw/pi-usage](https://www.npmjs.com/package/@narumitw/pi-usage) 0.65.0 | 84,983 | refused | pi extensions handle events yoagent never fires, which would decide or rewrite what the agent does: index.ts: event "before_provider_request" (list an event in allowUnmapped to accept that it goes unenforced) | ## DSH plugins -The 20 most-downloaded packages (by monthly downloads, among the first 1000 results of npm's search) with the `dsh-plugin` keyword, over DSH's base packages and the tools adapter (DSH 0.2.0-rc.2), on 2026-10-10. 3 not applicable · 13 needs a DSH host · 2 works, partly · 1 failed · 1 works. +The 20 most-downloaded packages (by monthly downloads, among the first 1000 results of npm's search) with the `dsh-plugin` keyword, over DSH's base packages and the tools adapter (DSH 0.2.0-rc.2), on 2026-10-12. 3 not applicable · 12 needs a DSH host · 3 works, partly · 1 failed · 1 works. | # | Package | Downloads / month | Result | What yoagent gets | |---|---|---:|---|---| -| 1 | [dshmarket](https://www.npmjs.com/package/dshmarket) 1.66.15 | 561,135 | not applicable | starts, but adds no tools or guidance (UI, settings or server features). Its web UI needs DSH's web client. | -| 2 | [@michengai/dsh-skills-manager](https://www.npmjs.com/package/@michengai/dsh-skills-manager) 1.1.13 | 201,182 | needs a DSH host | waits for services yoagent's DSH runtime does not run: webServer, webRuntime, skills, sessions. Its web UI needs DSH's web client. | -| 3 | [@michengai/dsh-archive-manager](https://www.npmjs.com/package/@michengai/dsh-archive-manager) 1.0.16 | 180,612 | needs a DSH host | loads DSH packages an agent library does not ship: @deepseek-ai/dsh-workspace. Its web UI needs DSH's web client. (installed without its DSH peers (@deepseek-ai/dsh-workspace@0.2.0-rc.2, @deepseek-ai/dsh-spill-local@0.2.0-rc.2, @deepseek-ai/dsh-client-store@0.2.0-rc.2, @deepseek-ai/dsh-client-locale@0.2.0-rc.2, @deepseek-ai/dsh-client-runtime@0.2.0-rc.2, @deepseek-ai/dsh-host-webserver@0.2.0-rc.2, @deepseek-ai/dsh-storage-domain@0.2.0-rc.2, @deepseek-ai/dsh-typert-registry@0.2.0-rc.2, @deepseek-ai/dsh-client-connection@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-sidebar@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-workspace@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-conversation@0.2.0-rc.2, @deepseek-ai/dsh-session-format-catalog@0.2.0-rc.2, @deepseek-ai/dsh-session-projection-cache@0.2.0-rc.2)) | -| 4 | [dsh-whale-widget](https://www.npmjs.com/package/dsh-whale-widget) 0.3.18 | 146,796 | not applicable | starts, but adds no tools or guidance (UI, settings or server features). | +| 1 | [dshmarket](https://www.npmjs.com/package/dshmarket) 1.66.17 | 570,846 | not applicable | starts, but adds no tools or guidance (UI, settings or server features). Its web UI needs DSH's web client. | +| 2 | [@michengai/dsh-skills-manager](https://www.npmjs.com/package/@michengai/dsh-skills-manager) 1.1.17 | 202,149 | needs a DSH host | waits for services yoagent's DSH runtime does not run: webServer, sessions. Its web UI needs DSH's web client. (installed without npm resolving its peers) | +| 3 | [@michengai/dsh-archive-manager](https://www.npmjs.com/package/@michengai/dsh-archive-manager) 1.0.17 | 182,870 | needs a DSH host | loads DSH packages an agent library does not ship: @deepseek-ai/dsh-workspace. Its web UI needs DSH's web client. (installed without its DSH peers (@deepseek-ai/dsh-workspace@0.2.0-rc.2, @deepseek-ai/dsh-spill-local@0.2.0-rc.2, @deepseek-ai/dsh-client-store@0.2.0-rc.2, @deepseek-ai/dsh-client-locale@0.2.0-rc.2, @deepseek-ai/dsh-client-runtime@0.2.0-rc.2, @deepseek-ai/dsh-host-webserver@0.2.0-rc.2, @deepseek-ai/dsh-storage-domain@0.2.0-rc.2, @deepseek-ai/dsh-typert-registry@0.2.0-rc.2, @deepseek-ai/dsh-client-connection@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-sidebar@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-workspace@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-primitives@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-conversation@0.2.0-rc.2, @deepseek-ai/dsh-session-format-catalog@0.2.0-rc.2, @deepseek-ai/dsh-session-projection-cache@0.2.0-rc.2)) | +| 4 | [dsh-whale-widget](https://www.npmjs.com/package/dsh-whale-widget) 0.4.2 | 154,055 | not applicable | starts, but adds no tools or guidance (UI, settings or server features). | | 5 | [dsh-plugin-model-proxy](https://www.npmjs.com/package/dsh-plugin-model-proxy) 0.1.6 | 131,719 | needs a DSH host | waits for services yoagent's DSH runtime does not run: settings, llm. Its web UI needs DSH's web client. | -| 6 | [dsh-cost-meter](https://www.npmjs.com/package/dsh-cost-meter) 1.8.20 | 108,950 | not applicable | starts, but adds no tools or guidance (UI, settings or server features). Its web UI needs DSH's web client. | -| 7 | [@xmanrui/dsh-im](https://www.npmjs.com/package/@xmanrui/dsh-im) 4.38.0 | 62,925 | needs a DSH host | waits for services yoagent's DSH runtime does not run: connection, credentials, typertGateway. Its web UI needs DSH's web client. | -| 8 | [@nanmicoder/dsh-agent-teams](https://www.npmjs.com/package/@nanmicoder/dsh-agent-teams) 0.1.22 | 55,140 | needs a DSH host | waits for services yoagent's DSH runtime does not run: llm, subagents, agents. Its web UI needs DSH's web client. | -| 9 | [@dfy-plugins/dsh-image-generation](https://www.npmjs.com/package/@dfy-plugins/dsh-image-generation) 0.1.4 | 52,343 | needs a DSH host | waits for services yoagent's DSH runtime does not run: attachments, credentials, fs, skills, sessionPersistence, settings. Its web UI needs DSH's web client. (installed without npm resolving its peers) | -| 10 | [dsh-univer-office](https://www.npmjs.com/package/dsh-univer-office) 0.3.7 | 49,557 | works, partly | 13 tools
ignored: its web UI (DSH's web client) | -| 11 | [dsh-mnemon](https://www.npmjs.com/package/dsh-mnemon) 0.5.27 | 44,342 | needs a DSH host | waits for services yoagent's DSH runtime does not run: loader. Its web UI needs DSH's web client. | -| 12 | [@liustack/modsearch](https://www.npmjs.com/package/@liustack/modsearch) 5.10.6 | 43,116 | failed | modsearch: failed plugin failed: TypeError: Cannot read properties of null (reading 'searchProvider') | -| 13 | [dsh-pet](https://www.npmjs.com/package/dsh-pet) 0.3.6 | 41,822 | needs a DSH host | waits for services yoagent's DSH runtime does not run: webServer, agentDefaultModel, credentials, llm, commands. Its web UI needs DSH's web client. | -| 14 | [@dfy-plugins/dsh-bundle](https://www.npmjs.com/package/@dfy-plugins/dsh-bundle) 0.1.2 | 41,676 | needs a DSH host | waits for services yoagent's DSH runtime does not run: webServer, settings, workspaceRegistry, llm, attachments, agents, credentials, fs, skills, sessionPersistence, agentPresets. | -| 15 | [dsh-plugin-wallpaper-engine](https://www.npmjs.com/package/dsh-plugin-wallpaper-engine) 1.3.1 | 41,148 | needs a DSH host | waits for services yoagent's DSH runtime does not run: webServer. Its web UI needs DSH's web client. (installed without its DSH peers (@deepseek-ai/dsh-client-runtime@0.2.0-rc.2, @deepseek-ai/dsh-host-webserver@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-slots@0.2.0-rc.2)) | -| 16 | [dsh-free-search](https://www.npmjs.com/package/dsh-free-search) 0.8.5 | 40,197 | works, partly | 9 tools, prompt guidance
ignored: its web UI (DSH's web client) | -| 17 | [@roarpeng/graphflow](https://www.npmjs.com/package/@roarpeng/graphflow) 2.2.0 | 34,657 | needs a DSH host | loads DSH packages an agent library does not ship: @deepseek-ai/dsh-mcp-client. Its web UI needs DSH's web client. | -| 18 | [dsh-dream-skin](https://www.npmjs.com/package/dsh-dream-skin) 10.10.0 | 33,804 | needs a DSH host | waits for services yoagent's DSH runtime does not run: webServer, webRuntime. Its web UI needs DSH's web client. (installed without its DSH peers (@deepseek-ai/dsh-client-store@0.2.0-rc.2, @deepseek-ai/dsh-client-locale@0.2.0-rc.2, @deepseek-ai/dsh-client-runtime@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-theme@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-settings@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-settings-general@0.2.0-rc.2)) | -| 19 | [@dfy-plugins/dsh-visualize](https://www.npmjs.com/package/@dfy-plugins/dsh-visualize) 0.1.5 | 30,262 | needs a DSH host | waits for services yoagent's DSH runtime does not run: fs, skills, sessionPersistence. Its web UI needs DSH's web client. (installed without npm resolving its peers) | +| 6 | [dsh-cost-meter](https://www.npmjs.com/package/dsh-cost-meter) 1.8.22 | 108,645 | not applicable | starts, but adds no tools or guidance (UI, settings or server features). Its web UI needs DSH's web client. | +| 7 | [@xmanrui/dsh-im](https://www.npmjs.com/package/@xmanrui/dsh-im) 4.38.1 | 62,286 | needs a DSH host | waits for services yoagent's DSH runtime does not run: connection, typertGateway. Its web UI needs DSH's web client. | +| 8 | [@nanmicoder/dsh-agent-teams](https://www.npmjs.com/package/@nanmicoder/dsh-agent-teams) 0.1.23 | 55,140 | needs a DSH host | waits for services yoagent's DSH runtime does not run: llm, subagents, agents. Its web UI needs DSH's web client. | +| 9 | [@dfy-plugins/dsh-image-generation](https://www.npmjs.com/package/@dfy-plugins/dsh-image-generation) 0.1.4 | 52,343 | needs a DSH host | waits for services yoagent's DSH runtime does not run: settings. Its web UI needs DSH's web client. (installed without npm resolving its peers) | +| 10 | [dsh-univer-office](https://www.npmjs.com/package/dsh-univer-office) 0.3.7 | 50,677 | works, partly | 14 tools
ignored: its web UI (DSH's web client) | +| 11 | [dsh-mnemon](https://www.npmjs.com/package/dsh-mnemon) 0.5.28 | 45,540 | needs a DSH host | waits for services yoagent's DSH runtime does not run: loader. Its web UI needs DSH's web client. | +| 12 | [dsh-plugin-wallpaper-engine](https://www.npmjs.com/package/dsh-plugin-wallpaper-engine) 1.3.2 | 43,299 | needs a DSH host | waits for services yoagent's DSH runtime does not run: webServer. Its web UI needs DSH's web client. (installed without its DSH peers (@deepseek-ai/dsh-client-runtime@0.2.0-rc.2, @deepseek-ai/dsh-host-webserver@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-slots@0.2.0-rc.2)) | +| 13 | [dsh-free-search](https://www.npmjs.com/package/dsh-free-search) 0.8.9 | 42,419 | works, partly | 9 tools, prompt guidance
ignored: its web UI (DSH's web client) | +| 14 | [@liustack/modsearch](https://www.npmjs.com/package/@liustack/modsearch) 5.10.6 | 42,230 | failed | modsearch: failed plugin failed: TypeError: Cannot read properties of null (reading 'searchProvider') | +| 15 | [dsh-pet](https://www.npmjs.com/package/dsh-pet) 0.3.6 | 41,822 | needs a DSH host | waits for services yoagent's DSH runtime does not run: webServer, agentDefaultModel, llm, commands. Its web UI needs DSH's web client. | +| 16 | [@dfy-plugins/dsh-bundle](https://www.npmjs.com/package/@dfy-plugins/dsh-bundle) 0.1.2 | 41,676 | needs a DSH host | waits for services yoagent's DSH runtime does not run: webServer, settings, workspaceRegistry, llm, agents, agentPresets. | +| 17 | [@roarpeng/graphflow](https://www.npmjs.com/package/@roarpeng/graphflow) 2.3.0 | 36,397 | needs a DSH host | loads DSH packages an agent library does not ship: @deepseek-ai/dsh-mcp-client. Its web UI needs DSH's web client. | +| 18 | [dsh-dream-skin](https://www.npmjs.com/package/dsh-dream-skin) 10.12.1 | 35,393 | needs a DSH host | waits for services yoagent's DSH runtime does not run: webServer, webRuntime. Its web UI needs DSH's web client. (installed without its DSH peers (@deepseek-ai/dsh-client-store@0.2.0-rc.2, @deepseek-ai/dsh-client-locale@0.2.0-rc.2, @deepseek-ai/dsh-client-runtime@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-theme@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-settings@0.2.0-rc.2, @deepseek-ai/dsh-client-ui-settings-general@0.2.0-rc.2)) | +| 19 | [@dfy-plugins/dsh-visualize](https://www.npmjs.com/package/@dfy-plugins/dsh-visualize) 0.1.5 | 30,262 | works, partly | 1 tool (installed without npm resolving its peers)
ignored: its web UI (DSH's web client) | | 20 | [dsh-find-plugin](https://www.npmjs.com/package/dsh-find-plugin) 0.4.0 | 29,744 | works | 1 tool | diff --git a/integrations/yoagent-frontend/CLAUDE.md b/integrations/yoagent-frontend/CLAUDE.md index a77638f..55fe867 100644 --- a/integrations/yoagent-frontend/CLAUDE.md +++ b/integrations/yoagent-frontend/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code when working in `integrations/yoagent-frontend/`. The r ## Frontend layer (`integrations/yoagent-frontend/`, EXPERIMENTAL, separate crate) -`publish = false`, no API promise; neither yoagent's core nor yoagent-rutis's API depends on it (its one touch outside: the pi adapter's optional dialogs, below). `Session::new(accept_quit) -> (Session, Driver)`: `Driver::run(agent) -> Agent` owns the agent; the `Driver` holds an `Ending` guard, so however it goes (returns, its task panics or is aborted, or it is dropped unrun) an open run gets `RunEnd{outcome: Error}`, the session is marked closed and every frontend gets `Closed`. `Session` (clone; only handles hold the command sender, so dropping them all ends the driver): `connect()` → `Connection { id, messages }` (own unbounded channel per frontend, `Hello` first, then the replay of `Inner.history` (the last `HISTORY` = 5000 `runStart`/`event`/`runEnd`/`notice` broadcasts, recorded in `Inner::broadcast` under `clients` then `history`, so a late frontend gets each message once; kept across a reset; questions not included — Hello carries the open ones) — then `Closed` at once if the session ended — built under the clients lock, nothing dropped; **lock order is `clients` → `ui`**: never take `clients` (`frontends()`, `broadcast`) while holding `ui`), `send(ClientMessage) -> bool` (false + `warn!` once closed; a `UiResponse` resolves its question only if `UiRequest::accepts` the value — confirm: bool, select: an option or null, input: string or null — else it stays open), `is_closed`, `notice(level, msg)`, crate-private `send_to(id, msg)`, `ask(UiRequest, timeout)` / `ask_keyed(.., key)` (no frontend → `default_answer` at once, logged; else broadcast `UiRequest`, first fitting answer wins; a `Close` guard removes the entry however `ask` ends and broadcasts `UiResolved{id, reason: Answered|TimedOut|Withdrawn}`; a key already in use is not taken over (warned) and the guard removes a key only if it maps to its own id), `ask_later(request)` (= `ask_inner(.., timeout None, hold true)`: kept open with no frontend attached (later ones get it in `Hello`), no timeout; the `Ending` guard clears `pending` under `clients` → `ui`, and `ask_inner` returns the default when `is_closed()` under the `ui` lock, so a held question never outlives the session), `withdraw(key)` (unknown key → remembered 60 s, so a withdrawal that overtakes its question returns the default without showing it), `add_ui_plugin(UiPlugin, module) -> Option` (the version; empty name → `None`), `remove_ui_plugin(name, Option)`, `ui_plugin_module`. Protocol (`protocol.rs`, tag `type`, camelCase, enums `#[non_exhaustive]`): in `prompt`/`steer`/`followUp`/`abort`/`reset`/`uiResponse`/`quit`; out `hello{running,uiPlugins,uiRequests}`, `runStart{run,prompt}`, `event{run,event}` (text/thinking deltas merged by `Coalesced`, held ≤30 ms), `runEnd{run,outcome: RunOutcome(completed|aborted|rejected|error),error,stats,totalCostUsd}` (always once per run; no `AgentEnd` → `error` + an error `Notice`; `InputRejected` → `rejected`; Abort/Reset/Quit → `aborted`; else the last assistant message), `uiRequest`/`uiResolved{id,reason}`, `notice{level,message}`, `uiPlugins`, `closed`. `UiPlugin.version` is `skip_deserializing` (set by the session). Prompt mid-run → queued; steer/followUp idle → a prompt; steering/follow-ups the loop never read (they arrived after its last look, e.g. while extensions `finish`) become the next run's prompt unless the run was aborted (a reset clears them); `Reset` mid-run aborts, drops the queue and clears history after `finish`. `services::provide(root, &session)`: host services `frontend` (`connect(client)` sync → `client.receive(msg)` per message in order, raced against the disposer / the caller's `Connection::closed`, a failed `receive` disconnects it; `send` async, `Err` once closed; `addUiPlugin(info, module)` sync → disposer removing that version only, `Err` on an empty name) and `ui` (`frontends()` sync → the attached count; `request(request, timeoutMs?)` → `Value::future`; `key` must be a string, `timeoutMs` null or a finite non-negative number (fractions ok); `withdraw(key)` string only); unit tests drive `invoke` directly. `web::serve(session, addr) -> Served` (private fields; `url()`, `addr()`, `token()`, `stop()`; dropping it stops serving) (axum 0.8, `ws`): `/`, `/app.js`, `/lib.js` (embedded `web/`), `/favicon.ico` 204, `/ui-plugins/{name}` (`.js`), `/ws` **requires `?t=`** (uuid v4; 403 otherwise — WebSockets skip CORS, so without it any page could drive the agent); a frame that is not the protocol → `warn!` + a `Notice` to that frontend; UI plugin modules are trusted code. Page: `web/lib.js` (no DOM; `web/package.json` marks `web/` as ESM — the terminal UI imports it, and rutis's loader would read it as CommonJS: `toolView(details)` (only the six card kinds), `viewTitle`, `diffRows` (LCS; unaligned when old × new line counts exceed 400²), `parseAnswer(request, text)` (the terminal UI's typed answers: `{value}` or `{error}` → asked again, never guessed), `safeUrl` (http(s)), `markdownParts` (https links only), `Questions` FIFO, `runEndLine`), tested by `node --test web/lib.test.js`; questions are a docked, non-modal `` (`show()`, fixed above the composer, `body.asking` pads the log, "1 of N questions waiting") — the page stays usable while one waits; `app.js` handles messages serially (a promise chain), refuses to send on a closed socket or ended session (says so; the prompt stays in the box, a dialog stays open). `host::PluginHost::builder().node(name, dir).share(svc).terminal(name)?.start(root)` (`terminal(name)`: that runtime inherits stdin and so stays in the terminal's process group — rutis 0.9 puts every other runtime in a group of its own, where touching the terminal stops it (SIGTTIN/SIGTTOU); `coding_agent` sets it for `ui` in terminal mode, `terminal-ui.ts` reads `process.stdin` when it is a TTY, else `/dev/tty`; CI drives the real terminal mode with `tests/terminal_pty.py` (pty: the screen draws, a typed prompt runs a tool, no process stopped), which the headless `--demo` cannot catch) → `load(rows)` (`Row::new(id, name).runtime(r).config(..).inject(..)`; a `Routed` resolver sends a row to its named runtime, others to the first that resolves; a name already routed to another runtime is refused) waits until rows are `Active` (reconcile counts `Pending` as settled), errors naming those that are not; a failed batch is dropped (rows and routes restored; a failed rollback is logged); `status()`; `unload(id)` (row dropped, its route too when no other row loads that name, reconcile; restored on failure) and `reload(id)` = `RuntimeResolver::invalidate(name)` on every runtime's resolver (kept in `PluginHost.runtimes`) + `Loader::reload(id)` (re-describe → the runner's `fresh()` imports the changed file with a `?rutis-reload=` query → row replaced) + wait until it runs. **Not atomic:** a module that fails to import/describe is refused before anything stops (old version keeps running), but one that fails in `apply` has already replaced the old row, and the loader's rollback re-imports the same (new) file — the plugin stays `Failed` until a later reload succeeds; the error says "running version stays" or "is stopped" (by `is_running(id)`); the example runs `.require_policy()` so a stopped pi policy denies calls. `load` waits only for the rows it added and fails only on `new_failures`, `unload` waits for none — a stopped plugin does not block other changes. **Without `invalidate`, `Loader::reload` is a no-op** for a Node file row (the resolver caches by package version, and a lone file has none) and `Loader::restart` re-applies the already-imported module. Only the plugin's own module is re-imported (not its imports); a `.ts` plugin must sit in a `"type": "module"` package (tsx loads it as CommonJS otherwise, whose cache ignores the query); pi extensions reload with the pi adapter (pi's jiti loader, `moduleCache: false`). `tests/host_test.rs` (temp ESM `.ts` plugin with a `version` tool: edit → new code, unchanged reload restarts, a syntax error refused with the old code kept, an `apply` that throws leaves it stopped (and another plugin still loads), the next good save brings it back, unload removes the tool). **A TS row is Active before its async `apply` finishes** — wait for the handlers you depend on. **A row-level `inject` makes the row wait for every name** (on rutis 0.7/0.8 it stopped a TS row from ever applying; 0.9 applies it — rechecked 2026-10-12); for a service a plugin may go without, re-provide it instead, as `host-ui.ts` does. Plugins (`plugins/`, own `package.json`: rutis 0.9.1, pi-tui 1.1.0): `terminal-ui.ts` (pi-tui on `frontend`; `/dev/tty` keyboard; questions answered in the editor, a stale prefill cleared when its question closes; `receive` never throws (shown instead); a failed quit goes to stderr; `demo` headless prints `[header, log, status]` and declines questions), `search-links.ts` (UI plugin: `renderTool` links for search tools, `mountPanel` quick prompts), `pi-extensions/confirm-dangerous.ts` (a pi permission gate using `ctx.ui.select`). pi dialogs: rutis shows a **host** service only to plugins that inject it and starts a plugin only once all injected services exist, so `yoagent-rutis/plugins/pi/host-ui.ts` injects `ui` and re-provides it in the pi runtime as `pi-host-ui`; the adapter looks that up per pi context (`lookupUi`; missing → print mode, said once at debug) → RPC mode (`mode: 'rpc'`, `hasUI: true`, `select`/`confirm`/`input`/`editor`/`notify` forwarded; failures → print-mode answers); each question keyed `pi-` and withdrawn on its signal's abort (a failed withdraw is reported at warn); without it, print mode unchanged. `examples/minimal_browser.rs`: the smallest use — `Session::new(false)` + `web::serve` + an agent with read-only tools (`list_files`, `read_file`, `search`) sandboxed to the cwd (`with_allowed_paths`; the built-ins allow every path by default), no plugins or Node; scripted, or `--live` DeepSeek; tried live in Chrome. `examples/coding_agent.rs`: runtimes `ui`/`pi`/`dsh`, terminal (default) or `--web [ADDR]` (8787), `--demo`, `--live` (`DEEPSEEK_API_KEY`), `--no-dsh`, `--watch` (a task owning the `PluginHost` polls the plugin files every 500 ms by mtime + size — row files, the pi extension → `pi`, terminal-ui in terminal mode — settles 200 ms (files changed meanwhile are reloaded too), `reload`s the owning row and sends every frontend a `Notice` — Info, Warning when the old version stays, Error when the plugin is stopped; tried live in Chrome: an edited pi extension's new question text, a broken save refused); waits for `pi-extensions`/`dsh-tools` handlers; `tracing-subscriber` (stderr with `--web`/`--demo`, else `$TMPDIR/yoagent-coding-agent.log`); a shutdown error never hides the run's. Tool cards: a result's `details.view` (DSH's card vocabulary) is drawn by `app.js` `card()` (terminal/diff/search/read/web/generic; `CARD_LINES` 200 per block, ≤20 web sources; a generic card with no text draws nothing; a card that throws → "(this tool's card could not be drawn)") unless a UI plugin draws that tool; the line takes `viewTitle` (browser: only then; terminal UI: always). `UiRequest::Select { title, message, options, multiple }` (answer: a list of distinct options with `multiple`; a frontend that cannot offer several must leave it unanswered), `Input` has `message` too; `ask_keyed` asks `request.normalized()` (a select's options each once). Tests: `session_test` (19, a `Gate` tool keeps runs in progress), `web_test` (4, tokio-tungstenite), `pi_dialog_test` (real pi runtime: Allow runs `bash`, Block stops it; skipped without `plugins/pi/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`), `dsh_dialog_test` (real DSH runtime: approval yes/no, `ask_user_question`, `details.view`; stopping a run withdraws its open approval (`UiResolved` withdrawn, `RunEnd` aborted, tool not run); skipped without `plugins/dsh/node_modules/@deepseek-ai/dsh-tool-ask-user` — re-run `npm ci` after a lock change), unit tests (`Coalesced`, services, `UiRequest::accepts`); CI job `frontend-experimental` (fmt, clippy, docs, tests with runtimes required, `node --test`, headless example asserting the pi question reached the terminal UI and `bash ✗`). Tried live in Chrome (DeepSeek + DSH search, links rendered by the UI plugin). +`publish = false`, no API promise; neither yoagent's core nor yoagent-rutis's API depends on it (its one touch outside: the pi adapter's optional dialogs, below). `Session::new(accept_quit) -> (Session, Driver)`: `Driver::run(agent) -> Agent` owns the agent; the `Driver` holds an `Ending` guard, so however it goes (returns, its task panics or is aborted, or it is dropped unrun) an open run gets `RunEnd{outcome: Error}`, the session is marked closed and every frontend gets `Closed`. `Session` (clone; only handles hold the command sender, so dropping them all ends the driver): `connect()` → `Connection { id, messages }` (own unbounded channel per frontend, `Hello` first, then the replay of `Inner.history` (the last `HISTORY` = 5000 `runStart`/`event`/`runEnd`/`notice` broadcasts, recorded in `Inner::broadcast` under `clients` then `history`, so a late frontend gets each message once; kept across a reset; questions not included — Hello carries the open ones) — then `Closed` at once if the session ended — built under the clients lock, nothing dropped; **lock order is `clients` → `ui`**: never take `clients` (`frontends()`, `broadcast`) while holding `ui`), `send(ClientMessage) -> bool` (false + `warn!` once closed; a `UiResponse` resolves its question only if `UiRequest::accepts` the value — confirm: bool, select: an option or null, input: string or null — else it stays open), `is_closed`, `notice(level, msg)`, crate-private `send_to(id, msg)`, `ask(UiRequest, timeout)` / `ask_keyed(.., key)` (no frontend → `default_answer` at once, logged; else broadcast `UiRequest`, first fitting answer wins; a `Close` guard removes the entry however `ask` ends and broadcasts `UiResolved{id, reason: Answered|TimedOut|Withdrawn}`; a key already in use is not taken over (warned) and the guard removes a key only if it maps to its own id), `ask_later(request)` (= `ask_inner(.., timeout None, hold true)`: kept open with no frontend attached (later ones get it in `Hello`), no timeout; the `Ending` guard clears `pending` under `clients` → `ui`, and `ask_inner` returns the default when `is_closed()` under the `ui` lock, so a held question never outlives the session), `withdraw(key)` (unknown key → remembered 60 s, so a withdrawal that overtakes its question returns the default without showing it), `add_ui_plugin(UiPlugin, module) -> Option` (the version; empty name → `None`), `remove_ui_plugin(name, Option)`, `ui_plugin_module`. Protocol (`protocol.rs`, tag `type`, camelCase, enums `#[non_exhaustive]`): in `prompt`/`steer`/`followUp`/`abort`/`reset`/`uiResponse`/`quit`; out `hello{running,uiPlugins,uiRequests}`, `runStart{run,prompt}`, `event{run,event}` (text/thinking deltas merged by `Coalesced`, held ≤30 ms), `runEnd{run,outcome: RunOutcome(completed|aborted|rejected|error),error,stats,totalCostUsd}` (always once per run; no `AgentEnd` → `error` + an error `Notice`; `InputRejected` → `rejected`; Abort/Reset/Quit → `aborted`; else the last assistant message), `uiRequest`/`uiResolved{id,reason}`, `notice{level,message}`, `uiPlugins`, `closed`. `UiPlugin.version` is `skip_deserializing` (set by the session). Prompt mid-run → queued; steer/followUp idle → a prompt; steering/follow-ups the loop never read (they arrived after its last look, e.g. while extensions `finish`) become the next run's prompt unless the run was aborted (a reset clears them); `Reset` mid-run aborts, drops the queue and clears history after `finish`. `services::provide(root, &session)`: host services `frontend` (`connect(client)` sync → `client.receive(msg)` per message in order, raced against the disposer / the caller's `Connection::closed`, a failed `receive` disconnects it; `send` async, `Err` once closed; `addUiPlugin(info, module)` sync → disposer removing that version only, `Err` on an empty name) and `ui` (`frontends()` sync → the attached count; `request(request, timeoutMs?)` → `Value::future`; `key` must be a string, `timeoutMs` null or a finite non-negative number (fractions ok); `withdraw(key)` string only); unit tests drive `invoke` directly. `web::serve(session, addr) -> Served` (private fields; `url()`, `addr()`, `token()`, `stop()`; dropping it stops serving) (axum 0.8, `ws`): `/`, `/app.js`, `/lib.js` (embedded `web/`), `/favicon.ico` 204, `/ui-plugins/{name}` (`.js`), `/ws` **requires `?t=`** (uuid v4; 403 otherwise — WebSockets skip CORS, so without it any page could drive the agent); a frame that is not the protocol → `warn!` + a `Notice` to that frontend; UI plugin modules are trusted code. Page: `web/lib.js` (no DOM; `web/package.json` marks `web/` as ESM — the terminal UI imports it, and rutis's loader would read it as CommonJS: `toolView(details)` (only the six card kinds), `viewTitle`, `diffRows` (LCS; unaligned when old × new line counts exceed 400²), `parseAnswer(request, text)` (the terminal UI's typed answers: `{value}` or `{error}` → asked again, never guessed), `safeUrl` (http(s)), `markdownParts` (https links only), `Questions` FIFO, `runEndLine`), tested by `node --test web/lib.test.js`; questions are a docked, non-modal `` (`show()`, fixed above the composer, `body.asking` pads the log, "1 of N questions waiting") — the page stays usable while one waits; `app.js` handles messages serially (a promise chain), refuses to send on a closed socket or ended session (says so; the prompt stays in the box, a dialog stays open). `host::PluginHost::builder().node(name, dir).share(svc).terminal(name)?.env(runtime, k, v)?.start(root)` (`env`: a runtime process's environment — the examples give the `dsh` runtime `DSH_HOME` = `YOAGENT_DSH_HOME`, else `$TMPDIR/yoagent-dsh-home`, never `~/.dsh`; `host::rows_from_file(path, runtime)` reads row lists such as yoagent-rutis's `plugins/dsh/host-services.json`, which both examples load before the DSH plugins) (`terminal(name)`: that runtime inherits stdin and so stays in the terminal's process group — rutis 0.9 puts every other runtime in a group of its own, where touching the terminal stops it (SIGTTIN/SIGTTOU); `coding_agent` sets it for `ui` in terminal mode, `terminal-ui.ts` reads `process.stdin` when it is a TTY, else `/dev/tty`; CI drives the real terminal mode with `tests/terminal_pty.py` (pty: the screen draws, a typed prompt runs a tool, no process stopped), which the headless `--demo` cannot catch) → `load(rows)` (`Row::new(id, name).runtime(r).config(..).inject(..)`; a `Routed` resolver sends a row to its named runtime, others to the first that resolves; a name already routed to another runtime is refused) waits until rows are `Active` (reconcile counts `Pending` as settled), errors naming those that are not; a failed batch is dropped (rows and routes restored; a failed rollback is logged); `status()`; `unload(id)` (row dropped, its route too when no other row loads that name, reconcile; restored on failure) and `reload(id)` = `RuntimeResolver::invalidate(name)` on every runtime's resolver (kept in `PluginHost.runtimes`) + `Loader::reload(id)` (re-describe → the runner's `fresh()` imports the changed file with a `?rutis-reload=` query → row replaced) + wait until it runs. **Not atomic:** a module that fails to import/describe is refused before anything stops (old version keeps running), but one that fails in `apply` has already replaced the old row, and the loader's rollback re-imports the same (new) file — the plugin stays `Failed` until a later reload succeeds; the error says "running version stays" or "is stopped" (by `is_running(id)`); the example runs `.require_policy()` so a stopped pi policy denies calls. `load` waits only for the rows it added and fails only on `new_failures`, `unload` waits for none — a stopped plugin does not block other changes. **Without `invalidate`, `Loader::reload` is a no-op** for a Node file row (the resolver caches by package version, and a lone file has none) and `Loader::restart` re-applies the already-imported module. Only the plugin's own module is re-imported (not its imports); a `.ts` plugin must sit in a `"type": "module"` package (tsx loads it as CommonJS otherwise, whose cache ignores the query); pi extensions reload with the pi adapter (pi's jiti loader, `moduleCache: false`). `tests/host_test.rs` (temp ESM `.ts` plugin with a `version` tool: edit → new code, unchanged reload restarts, a syntax error refused with the old code kept, an `apply` that throws leaves it stopped (and another plugin still loads), the next good save brings it back, unload removes the tool). **A TS row is Active before its async `apply` finishes** — wait for the handlers you depend on. **A row-level `inject` makes the row wait for every name** (on rutis 0.7/0.8 it stopped a TS row from ever applying; 0.9 applies it — rechecked 2026-10-12); for a service a plugin may go without, re-provide it instead, as `host-ui.ts` does. Plugins (`plugins/`, own `package.json`: rutis 0.9.1, pi-tui 1.1.0): `terminal-ui.ts` (pi-tui on `frontend`; `/dev/tty` keyboard; questions answered in the editor, a stale prefill cleared when its question closes; `receive` never throws (shown instead); a failed quit goes to stderr; `demo` headless prints `[header, log, status]` and declines questions), `search-links.ts` (UI plugin: `renderTool` links for search tools, `mountPanel` quick prompts), `pi-extensions/confirm-dangerous.ts` (a pi permission gate using `ctx.ui.select`). pi dialogs: rutis shows a **host** service only to plugins that inject it and starts a plugin only once all injected services exist, so `yoagent-rutis/plugins/pi/host-ui.ts` injects `ui` and re-provides it in the pi runtime as `pi-host-ui`; the adapter looks that up per pi context (`lookupUi`; missing → print mode, said once at debug) → RPC mode (`mode: 'rpc'`, `hasUI: true`, `select`/`confirm`/`input`/`editor`/`notify` forwarded; failures → print-mode answers); each question keyed `pi-` and withdrawn on its signal's abort (a failed withdraw is reported at warn); without it, print mode unchanged. `examples/minimal_browser.rs`: the smallest use — `Session::new(false)` + `web::serve` + an agent with read-only tools (`list_files`, `read_file`, `search`) sandboxed to the cwd (`with_allowed_paths`; the built-ins allow every path by default), no plugins or Node; scripted, or `--live` DeepSeek; tried live in Chrome. `examples/coding_agent.rs`: runtimes `ui`/`pi`/`dsh`, terminal (default) or `--web [ADDR]` (8787), `--demo`, `--live` (`DEEPSEEK_API_KEY`), `--no-dsh`, `--watch` (a task owning the `PluginHost` polls the plugin files every 500 ms by mtime + size — row files, the pi extension → `pi`, terminal-ui in terminal mode — settles 200 ms (files changed meanwhile are reloaded too), `reload`s the owning row and sends every frontend a `Notice` — Info, Warning when the old version stays, Error when the plugin is stopped; tried live in Chrome: an edited pi extension's new question text, a broken save refused); waits for `pi-extensions`/`dsh-tools` handlers; `tracing-subscriber` (stderr with `--web`/`--demo`, else `$TMPDIR/yoagent-coding-agent.log`); a shutdown error never hides the run's. Tool cards: a result's `details.view` (DSH's card vocabulary) is drawn by `app.js` `card()` (terminal/diff/search/read/web/generic; `CARD_LINES` 200 per block, ≤20 web sources; a generic card with no text draws nothing; a card that throws → "(this tool's card could not be drawn)") unless a UI plugin draws that tool; the line takes `viewTitle` (browser: only then; terminal UI: always). `UiRequest::Select { title, message, options, multiple }` (answer: a list of distinct options with `multiple`; a frontend that cannot offer several must leave it unanswered), `Input` has `message` too; `ask_keyed` asks `request.normalized()` (a select's options each once). Tests: `session_test` (19, a `Gate` tool keeps runs in progress), `web_test` (4, tokio-tungstenite), `pi_dialog_test` (real pi runtime: Allow runs `bash`, Block stops it; skipped without `plugins/pi/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`), `dsh_dialog_test` (real DSH runtime: approval yes/no, `ask_user_question`, `details.view`; stopping a run withdraws its open approval (`UiResolved` withdrawn, `RunEnd` aborted, tool not run); skipped without `plugins/dsh/node_modules/@deepseek-ai/dsh-tool-ask-user` — re-run `npm ci` after a lock change), unit tests (`Coalesced`, services, `UiRequest::accepts`); CI job `frontend-experimental` (fmt, clippy, docs, tests with runtimes required, `node --test`, headless example asserting the pi question reached the terminal UI and `bash ✗`). Tried live in Chrome (DeepSeek + DSH search, links rendered by the UI plugin). `scripts/try-coding-agent.sh` (repo root): checks cargo / Node 24+ / npm, `npm ci` in the three plugin dirs when `node_modules/.package-lock.json` is missing or older than the lockfile, then runs `coding_agent` with `--web` unless `--terminal` (or `--web`/`--demo` given); `--setup-only`; `--live` needs `DEEPSEEK_API_KEY`; empty-array expansions are bash-3.2 safe. diff --git a/integrations/yoagent-frontend/examples/coding_agent.rs b/integrations/yoagent-frontend/examples/coding_agent.rs index fc98b72..0195279 100644 --- a/integrations/yoagent-frontend/examples/coding_agent.rs +++ b/integrations/yoagent-frontend/examples/coding_agent.rs @@ -39,7 +39,7 @@ use yoagent::provider::mock::{MockResponse, MockToolCall}; use yoagent::provider::{MockProvider, ModelConfig}; use yoagent::tools::default_tools; use yoagent::Agent; -use yoagent_frontend::host::{PluginHost, Row}; +use yoagent_frontend::host::{rows_from_file, PluginHost, Row}; use yoagent_frontend::{services, session, web, NoticeLevel, Session}; use yoagent_rutis::RutisBridge; @@ -162,7 +162,10 @@ async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { .node("ui", here().join("plugins")) .node("pi", rutis_plugins().join("pi")); if options.dsh { - builder = builder.node("dsh", rutis_plugins().join("dsh")); + builder = + builder + .node("dsh", rutis_plugins().join("dsh")) + .env("dsh", "DSH_HOME", dsh_home()); } if options.web.is_none() && options.demo.is_none() { // The terminal UI reads the keyboard: its runtime keeps the terminal. @@ -200,6 +203,11 @@ async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { .runtime("ui"), ]; if options.dsh { + // The services a headless DSH host runs, for plugins that need them. + rows.extend(rows_from_file( + &rutis_plugins().join("dsh/host-services.json"), + "dsh", + )?); let dsh = |id: &str, name: &str| Row::new(id, name).runtime("dsh"); rows.extend([ dsh("dsh-web", "@deepseek-ai/dsh-web"), @@ -414,3 +422,16 @@ fn watch( } }) } + +/// DSH's home for its own services (fs, attachments, credentials, storage, +/// session persistence, skills): `YOAGENT_DSH_HOME`, else a directory of +/// yoagent's own under the temp dir — never the user's `~/.dsh`, whose +/// credentials and skills DSH plugins would otherwise see. Point +/// `YOAGENT_DSH_HOME` at `~/.dsh` to share them on purpose. +fn dsh_home() -> std::path::PathBuf { + let home = std::env::var_os("YOAGENT_DSH_HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("yoagent-dsh-home")); + let _ = std::fs::create_dir_all(&home); + home +} diff --git a/integrations/yoagent-frontend/examples/handoff_agent.rs b/integrations/yoagent-frontend/examples/handoff_agent.rs index 71e247b..97c3ff4 100644 --- a/integrations/yoagent-frontend/examples/handoff_agent.rs +++ b/integrations/yoagent-frontend/examples/handoff_agent.rs @@ -57,7 +57,7 @@ use yoagent::{ Agent, AgentEvent, AgentMessage, AgentTool, Content, Message, ToolContext, ToolError, ToolResult, }; -use yoagent_frontend::host::{PluginHost, Row}; +use yoagent_frontend::host::{rows_from_file, PluginHost, Row}; use yoagent_frontend::{ services, session, web, ClientMessage, NoticeLevel, ServerMessage, Session, UiRequest, }; @@ -219,7 +219,10 @@ async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { .node("ui", here().join("plugins")) .node("pi", rutis_plugins().join("pi")); if options.dsh { - builder = builder.node("dsh", rutis_plugins().join("dsh")); + builder = + builder + .node("dsh", rutis_plugins().join("dsh")) + .env("dsh", "DSH_HOME", dsh_home()); } let mut host = builder .share("yoagent") @@ -251,6 +254,11 @@ async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { Row::new("todo-plan", here().join("plugins/todo-plan.ts").display()).runtime("ui"), ]; if options.dsh { + // The services a headless DSH host runs, for plugins that need them. + rows.extend(rows_from_file( + &rutis_plugins().join("dsh/host-services.json"), + "dsh", + )?); let dsh = |id: &str, name: &str| Row::new(id, name).runtime("dsh"); rows.extend([ dsh("dsh-web", "@deepseek-ai/dsh-web"), @@ -870,3 +878,16 @@ async fn demo( println!("ok: asked without waiting, answered later, wrote notes.md, two reports"); Ok(()) } + +/// DSH's home for its own services (fs, attachments, credentials, storage, +/// session persistence, skills): `YOAGENT_DSH_HOME`, else a directory of +/// yoagent's own under the temp dir — never the user's `~/.dsh`, whose +/// credentials and skills DSH plugins would otherwise see. Point +/// `YOAGENT_DSH_HOME` at `~/.dsh` to share them on purpose. +fn dsh_home() -> std::path::PathBuf { + let home = std::env::var_os("YOAGENT_DSH_HOME") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::env::temp_dir().join("yoagent-dsh-home")); + let _ = std::fs::create_dir_all(&home); + home +} diff --git a/integrations/yoagent-frontend/src/host.rs b/integrations/yoagent-frontend/src/host.rs index f7454f0..f9ff114 100644 --- a/integrations/yoagent-frontend/src/host.rs +++ b/integrations/yoagent-frontend/src/host.rs @@ -19,11 +19,12 @@ //! ``` use std::collections::HashMap; -use std::path::PathBuf; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use rutis::{BoxFuture, Ctx, FiberState}; -use rutis_bridge::runtime::{LocalRuntime, Stdio}; +use rutis_bridge::runtime::{Launcher, LocalRuntime, Stdio}; use rutis_loader::{ Chain, EntryStatus, Layer, Loader, LoaderError, LoaderOptions, LoaderPlugin, Patch, Resolved, Resolver, RuntimeResolver, RuntimeRowsPlugin, ServiceCatalog, @@ -91,11 +92,38 @@ impl Row { } } +/// The rows listed in the JSON file `path` (`[{ "id", "name", "config"? }]`, +/// such as `yoagent-rutis/plugins/dsh/host-services.json`), each routed to +/// the runtime `runtime`. +pub fn rows_from_file(path: &Path, runtime: &str) -> Result, BoxError> { + #[derive(serde::Deserialize)] + struct Listed { + id: String, + name: String, + #[serde(default)] + config: Option, + } + let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; + let listed: Vec = + serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?; + Ok(listed + .into_iter() + .map(|row| { + let next = Row::new(row.id, row.name).runtime(runtime); + match row.config { + Some(config) => next.config(config), + None => next, + } + }) + .collect()) +} + #[derive(Default)] pub struct PluginHostBuilder { runtimes: Vec<(String, PathBuf)>, shared: Vec, terminal: Option, + env: Vec<(String, OsString, OsString)>, } impl PluginHostBuilder { @@ -116,6 +144,19 @@ impl PluginHostBuilder { self } + /// Set the environment variable `name` for the runtime `runtime`'s + /// process (say DSH's `DSH_HOME`, where DSH's own services keep their + /// data and read credentials). + pub fn env( + mut self, + runtime: impl Into, + name: impl Into, + value: impl Into, + ) -> Self { + self.env.push((runtime.into(), name.into(), value.into())); + self + } + /// A host service plugins may inject (`yoagent`, `frontend`, `ui`, …). pub fn share(mut self, service: impl Into) -> Self { self.shared.push(service.into()); @@ -131,11 +172,16 @@ impl PluginHostBuilder { let routes: Arc>> = Arc::default(); let mut resolvers = Vec::new(); for (name, dir) in &self.runtimes { - let mut runtime = LocalRuntime::node( - dir.join("node_modules/@arcships/rutis-runtime"), - dir.join("package.json"), - ) - .named(name.clone()); + let launcher = self + .env + .iter() + .filter(|(runtime, ..)| runtime == name) + .fold( + Launcher::node(&dir.join("node_modules/@arcships/rutis-runtime")), + |launcher, (_, key, value)| launcher.env(key, value), + ); + let mut runtime = + LocalRuntime::launcher(name.clone(), launcher, dir.join("package.json")); if self.terminal.as_ref() == Some(name) { runtime = runtime.stdin(Stdio::Inherit); } diff --git a/integrations/yoagent-rutis/CLAUDE.md b/integrations/yoagent-rutis/CLAUDE.md index f7d69b3..8e745ad 100644 --- a/integrations/yoagent-rutis/CLAUDE.md +++ b/integrations/yoagent-rutis/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code when working in `integrations/yoagent-rutis/`. The repo ## rutis bridge (`integrations/yoagent-rutis/`, separate crate) -Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.9"` (0.x caret = 0.9.x; since 0.8 the packages in rutis's release train share one version — move the host and the npm/PyPI runtimes together: a 0.9 host sends call origins a 0.8 runtime rejects; rutis 0.9 needs Rust 1.88, so `rust-version` is 1.88 (yoagent's own stays 1.86); any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Bun / Python plugins** (`languages.rs`, features `node`/`bun`/`python`/`websocket` → optional `rutis-bridge = "0.9"`; `bun`: Bun 1.4+ runs the same TS plugins (`@arcships/rutis-bun` 0.9.1 in `plugins/package.json`, `LocalRuntime::bun(package, project).interpreter(exe)` + `RuntimeResolver::modules` → rows `bun:`); `languages_test` runs `every_hook` / `signal_aborts` on Node and on Bun (`bun_runtime()`: `YOAGENT_RUTIS_BUN` or `bun` on PATH, 1.4+; the rutis-loader dev-dependency enables `bun`); CI installs it with `oven-sh/setup-bun` 1.4; `go`: Go plugins via the rutis Go SDK (`plugins/go/` — own `go.mod` (`go 1.24`, `github.com/arcships/rutis/go/rutis` v0.9.1) — registers `go-policy`: a `before_tool` denying `bash`, `slow_gate` waiting on its call `ctx.Done()`, a `go_echo` tool; the handler is a `map[string]any` of funcs (functions cross by reference; the `signal` field decodes as nil; the call's context also ends when the hook returns (Go's `defer cancel`), so "never cancelled after completing" is not observable from Go; the host's cancel cancels the call's context)); `tests/go_test.rs` (`required-features = ["go"]`) builds it with `go build`, runs it through `GoResolver` + `GoRuntimes` (rows `go:go-policy`), checks the tool + an `after_tool` redaction, that Go's denial / returned error / malformed answer each deny, that a policy timeout and a run cancel cancel Go's context, and that unloading the row removes the handler (disposer `Effect`), after which `bash` runs; a failed `go build` with Go present fails, never skips (Go 1.24+ or `SKIPPED:`); CI `actions/setup-go` 1.24, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.9 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (per image; rutis 0.9 closes a channel on any message over 16 MiB — local `MAX_MESSAGE` and websocket — ending the runtime, so: every host→plugin call goes through `fit` (`MAX_SEND` 15 MiB of serialized args; over → image blocks become a `[mime, N MB: omitted, too large to send to a plugin]` text block — `Oversize::OmitImagesInMessages` (hooks: only in arg 0's `messages`, never a call's `args`, which a policy may return as a rewrite) / `OmitImages` (`on_event`: the whole event) — still over → `ExtensionError` (deny / skip / tool fails); `after_tool` and `call_tool` use `call_exact` = `Oversize::Refuse` (after dropping `after_tool`'s duplicate `text`), so an oversized output is withheld and oversized args fail the tool, never altered; the over-limit warning is logged once per process, then debug; the pi adapter keys `message_end`'s seen set by `seenAs` (each image as `mime, N MB`, the host placeholder likewise), so a message is checked once whether it arrived whole or omitted; tested end to end in `languages_test` `a_conversation_over_the_message_limit…` (fails without the guard); plugin→host can't be guarded host-side: both adapters cap a result's images at `MAX_RESULT_IMAGE_CHARS` 12 MiB base64, the rest named in text), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7+ allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.9.1, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; past `MAX_RESULT_IMAGE_CHARS` 12 MiB of base64 per result → `[…: left out, the result's images are too large together]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.9.1; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `info` report each (`reported` set; expected, not a fault — refusals stay `warn`; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.9.1 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.9.1), `requirements.txt` (`rutis==0.9.1`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 22.19+ / `plugins/node_modules` / Python 3.10+ with rutis 0.9 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.88, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 22 — the floor; pi needs 22.19, rutis 0.9 22 — Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. +Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.9"` (0.x caret = 0.9.x; since 0.8 the packages in rutis's release train share one version — move the host and the npm/PyPI runtimes together: a 0.9 host sends call origins a 0.8 runtime rejects; rutis 0.9 needs Rust 1.88, so `rust-version` is 1.88 (yoagent's own stays 1.86); any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Bun / Python plugins** (`languages.rs`, features `node`/`bun`/`python`/`websocket` → optional `rutis-bridge = "0.9"`; `bun`: Bun 1.4+ runs the same TS plugins (`@arcships/rutis-bun` 0.9.1 in `plugins/package.json`, `LocalRuntime::bun(package, project).interpreter(exe)` + `RuntimeResolver::modules` → rows `bun:`); `languages_test` runs `every_hook` / `signal_aborts` on Node and on Bun (`bun_runtime()`: `YOAGENT_RUTIS_BUN` or `bun` on PATH, 1.4+; the rutis-loader dev-dependency enables `bun`); CI installs it with `oven-sh/setup-bun` 1.4; `go`: Go plugins via the rutis Go SDK (`plugins/go/` — own `go.mod` (`go 1.24`, `github.com/arcships/rutis/go/rutis` v0.9.1) — registers `go-policy`: a `before_tool` denying `bash`, `slow_gate` waiting on its call `ctx.Done()`, a `go_echo` tool; the handler is a `map[string]any` of funcs (functions cross by reference; the `signal` field decodes as nil; the call's context also ends when the hook returns (Go's `defer cancel`), so "never cancelled after completing" is not observable from Go; the host's cancel cancels the call's context)); `tests/go_test.rs` (`required-features = ["go"]`) builds it with `go build`, runs it through `GoResolver` + `GoRuntimes` (rows `go:go-policy`), checks the tool + an `after_tool` redaction, that Go's denial / returned error / malformed answer each deny, that a policy timeout and a run cancel cancel Go's context, and that unloading the row removes the handler (disposer `Effect`), after which `bash` runs; a failed `go build` with Go present fails, never skips (Go 1.24+ or `SKIPPED:`); CI `actions/setup-go` 1.24, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.9 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (per image; rutis 0.9 closes a channel on any message over 16 MiB — local `MAX_MESSAGE` and websocket — ending the runtime, so: every host→plugin call goes through `fit` (`MAX_SEND` 15 MiB of serialized args; over → image blocks become a `[mime, N MB: omitted, too large to send to a plugin]` text block — `Oversize::OmitImagesInMessages` (hooks: only in arg 0's `messages`, never a call's `args`, which a policy may return as a rewrite) / `OmitImages` (`on_event`: the whole event) — still over → `ExtensionError` (deny / skip / tool fails); `after_tool` and `call_tool` use `call_exact` = `Oversize::Refuse` (after dropping `after_tool`'s duplicate `text`), so an oversized output is withheld and oversized args fail the tool, never altered; the over-limit warning is logged once per process, then debug; the pi adapter keys `message_end`'s seen set by `seenAs` (each image as `mime, N MB`, the host placeholder likewise), so a message is checked once whether it arrived whole or omitted; tested end to end in `languages_test` `a_conversation_over_the_message_limit…` (fails without the guard); plugin→host can't be guarded host-side: both adapters cap a result's images at `MAX_RESULT_IMAGE_CHARS` 12 MiB base64, the rest named in text), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7+ allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.9.1, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1, and the **host services** (`plugins/dsh/host-services.json`: `dsh-fs-local`, `dsh-attachment-local`, `dsh-credentials-local`, `dsh-storage`, `dsh-session-persistence`, `dsh-skill`, `dsh-skill-filesystem` — the `-local` packages provide `fs`/`attachments`/`credentials` themselves; loading the interface package beside one fails "service registered"; they keep data and read credentials under `DSH_HOME` (else `~/.dsh`), so hosts set the runtime's `DSH_HOME` (probe: a temp dir; frontend examples: `YOAGENT_DSH_HOME`, else `$TMPDIR/yoagent-dsh-home`); `settings` needs DSH app boot (configEditor, profileContext, Cordis loader) and `subagents`/`llm`/`agents` DSH's model layer — not provided; test `dsh_host_services_start_headless_in_their_own_dsh_home`); separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; past `MAX_RESULT_IMAGE_CHARS` 12 MiB of base64 per result → `[…: left out, the result's images are too large together]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.9.1; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `info` report each (`reported` set; expected, not a fault — refusals stay `warn`; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.9.1 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.9.1), `requirements.txt` (`rutis==0.9.1`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 22.19+ / `plugins/node_modules` / Python 3.10+ with rutis 0.9 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.88, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 22 — the floor; pi needs 22.19, rutis 0.9 22 — Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. ### Plugin compatibility matrix diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index e5c98d5..52511e9 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -323,6 +323,22 @@ share `yoagent` in the loader's catalog. `plugins/dsh/package.json` pins everything exactly (dsh 0.2.0-rc.2 is a release candidate) and is its own install, so the language tests' stays small. +**DSH's own services, for plugins that need them.** Many dsh plugins wait +for services of the DSH host — files, attachments, credentials, storage, +session persistence, skills. DSH ships each as a package, and they run +headless: [`plugins/dsh/host-services.json`](plugins/dsh/host-services.json) +lists them as rows (`dsh-fs-local`, `dsh-attachment-local`, +`dsh-credentials-local`, `dsh-storage`, `dsh-session-persistence`, +`dsh-skill`, `dsh-skill-filesystem`); load them before the plugins. They keep +their data, and read credentials (`.credentials.yaml`, `.env`), under +`DSH_HOME`, else `~/.dsh`: **give the runtime a `DSH_HOME` of the host's +own** (rutis's `Launcher::env`; yoagent-frontend's `PluginHostBuilder::env`) +unless the plugins should see the user's DSH keys and skills. A plugin that +needs DSH's app — its web server and UI, its model layer (`llm`), agents, +`settings` — still does not run; the +[plugin compatibility](../../docs/evals/plugin-compatibility.md) report +says which, per package. + ```sh (cd plugins/dsh && npm ci) cargo run --features node --example dsh_tools # scripted model, real web search: needs network @@ -334,8 +350,9 @@ cargo run --features node --example dsh_tools -- --live # DeepSeek: DEEPSEEK_AP the adapter, runs one search, and checks that a dsh tool answered (and, scripted, that free-search's prompt section reached the model). `tests/dsh_test.rs` covers the adapter offline with a fixture dsh plugin -(`plugins/dsh/fixture-tools.ts`) and, for images, a stand-in attachment -store (`plugins/dsh/fixture-attachments.ts`). +(`plugins/dsh/fixture-tools.ts`), for images a stand-in attachment +store (`plugins/dsh/fixture-attachments.ts`), and that the host services +start headless in a `DSH_HOME` of their own. **Asking the user.** On its own, a dsh tool call the adapter makes cannot reach a person: an `ask` from a dsh tool policy is denied, and diff --git a/integrations/yoagent-rutis/tests/dsh_test.rs b/integrations/yoagent-rutis/tests/dsh_test.rs index 8d72b80..731378f 100644 --- a/integrations/yoagent-rutis/tests/dsh_test.rs +++ b/integrations/yoagent-rutis/tests/dsh_test.rs @@ -22,7 +22,7 @@ use std::time::Duration; use common::*; use rutis::Ctx; -use rutis_bridge::runtime::LocalRuntime; +use rutis_bridge::runtime::{Launcher, LocalRuntime}; use rutis_bridge::session::{ host_key, Error as BridgeError, HostDispatch, Reply, Value as BridgeValue, }; @@ -122,6 +122,11 @@ impl HostDispatch for ScriptedUi { } async fn host(runtime: &Path, ui: Option>) -> Host { + host_with(runtime, ui, None).await +} + +/// [`host`], the DSH runtime given `DSH_HOME` when `dsh_home` is set. +async fn host_with(runtime: &Path, ui: Option>, dsh_home: Option<&Path>) -> Host { let root = Ctx::root().unwrap(); let bridge = RutisBridge::install(&root).unwrap(); let mut catalog = ServiceCatalog::new(); @@ -131,7 +136,11 @@ async fn host(runtime: &Path, ui: Option>) -> Host { root.provide_as::(host_key("ui"), ui) .unwrap(); } - let node = LocalRuntime::node(runtime, dsh_dir().join("package.json")); + let launcher = match dsh_home { + Some(home) => Launcher::node(runtime).env("DSH_HOME", home), + None => Launcher::node(runtime), + }; + let node = LocalRuntime::launcher("node", launcher, dsh_dir().join("package.json")); let resolver = Arc::new(RuntimeResolver::node(node.handle()).with_catalog(&catalog)); root.plugin(node); let plugin = LoaderPlugin::new( @@ -630,3 +639,49 @@ async fn without_a_frontend_a_dsh_ask_is_denied() { host.root.shutdown().await.unwrap(); } } + +/// `plugins/dsh/host-services.json`: DSH's own services start headless, in a +/// DSH home of the host's choosing, and the services DSH plugins wait for +/// exist in the runtime (checked from inside it, as a plugin sees them). +#[tokio::test(flavor = "multi_thread")] +async fn dsh_host_services_start_headless_in_their_own_dsh_home() { + let Some(runtime) = node_runtime() else { + return; + }; + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("dsh-home"); + std::fs::create_dir_all(&home).unwrap(); + let out = dir.path().join("services.json"); + let host = host_with(&runtime, None, Some(&home)).await; + let services = [ + "fs", + "attachments", + "credentials", + "storage", + "sessionPersistence", + "skills", + ]; + let mut rows: Vec = serde_json::from_str( + &std::fs::read_to_string(dsh_dir().join("host-services.json")).unwrap(), + ) + .unwrap(); + rows.extend([ + json!({ "id": "system-prompt", "name": "@deepseek-ai/dsh-system-prompt" }), + json!({ "id": "tools", "name": "@deepseek-ai/dsh-tools" }), + json!({ "id": "adapter", "name": dsh_dir().join("dsh-tools-adapter.ts") }), + json!({ + "id": "probe-services", + "name": dsh_dir().join("probe-services.ts"), + "config": { "names": services, "out": out, "waitMs": 1000 }, + }), + ]); + host.load(Value::Array(rows)).await; + let report: Value = serde_json::from_str(&wait_file(&out, |t| !t.is_empty()).await).unwrap(); + assert_eq!(report["missing"], json!([]), "{report}"); + assert_eq!( + report["present"].as_array().map(Vec::len), + Some(services.len()), + "{report}" + ); + host.root.shutdown().await.unwrap(); +} From 73ddb9aceb6fcc595d4fcad2b7f10cce67272fb6 Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Mon, 12 Oct 2026 14:31:09 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(dsh=20host=20services):=20review=20?= =?UTF-8?q?=E2=80=94=20opt-in,=20a=20private=20DSH=20home,=20precise=20cla?= =?UTF-8?q?ims,=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - yoagent-frontend `DshHome`: YOAGENT_DSH_HOME (an empty one is unset — DSH would read it as ~/.dsh), else a private owner-only (0700) dir per run, removed on drop (was a fixed shared /tmp path, error ignored); env() = DSH_HOME and, for a private home, DSH_AGENTS_HOME inside it, so ~/.agents skills stay out too. - PluginHostBuilder::env for a runtime that is not started fails `start`. - coding_agent / handoff_agent load the host services only with `--dsh-services`: their own DSH plugins need none of them, and they give plugins the filesystem and a credentials reader. - Docs say what a separate home does and does not keep out: not the inherited environment (DSH's credentials service reads it first), the project's .env and skill folders, or the filesystem (dsh-fs-local). - Tests: dsh_test proves isolation (a plugin reads a key from the given DSH_HOME's .credentials.yaml, source `file`; nothing created under HOME); host_test: env reaches only its runtime, a typo'd runtime is an error; unit tests for rows_from_file, the shared list and DshHome (0700, removed on drop). The probe names the cause when host-services.json cannot be read, and sets DSH_AGENTS_HOME too. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- CHANGELOG.md | 4 +- integrations/yoagent-frontend/CLAUDE.md | 2 +- integrations/yoagent-frontend/Cargo.toml | 2 + .../yoagent-frontend/examples/coding_agent.rs | 50 +++--- .../examples/handoff_agent.rs | 48 +++--- integrations/yoagent-frontend/src/host.rs | 155 +++++++++++++++++- .../yoagent-frontend/tests/host_test.rs | 69 ++++++++ integrations/yoagent-rutis/CLAUDE.md | 2 +- integrations/yoagent-rutis/README.md | 16 +- .../yoagent-rutis/examples/ecosystem_probe.rs | 20 ++- integrations/yoagent-rutis/tests/dsh_test.rs | 88 ++++++++-- 11 files changed, 374 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f990aa..1236a2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,8 +19,8 @@ adheres to [Semantic Versioning](https://semver.org/). ### yoagent-rutis (unreleased) -- **DSH's own host services for dsh plugins**: `plugins/dsh/host-services.json` lists the DSH packages that provide what many dsh plugins wait for — files, attachments, credentials, storage, session persistence, skills — and run headless; the probe and the frontend examples load them. They keep data and read credentials under `DSH_HOME`, so hosts give the runtime a home of its own (the examples: `YOAGENT_DSH_HOME`, else a temp dir), never the user's `~/.dsh` unless asked. In the [plugin compatibility](docs/evals/plugin-compatibility.md) matrix (re-measured on rutis 0.9) DSH's top 20 go from 3 to 4 usable (`@dfy-plugins/dsh-visualize`); what still blocks the rest is DSH's app — its web UI, model layer, agents and settings. pi unchanged at 12/20. -- yoagent-frontend: `PluginHostBuilder::env(runtime, name, value)` and `host::rows_from_file`. +- **DSH's own host services for dsh plugins**: `plugins/dsh/host-services.json` lists the DSH packages that provide what many dsh plugins wait for — files, attachments, credentials, storage, session persistence, skills — and run headless; the compatibility probe loads them, the frontend examples on request (`--dsh-services`). Attachments, the credentials file and skills live under `DSH_HOME` (else `~/.dsh`) and skills also under `DSH_AGENTS_HOME` (else `~/.agents`), so hosts give the runtime homes of their own; that keeps `~/.dsh` and `~/.agents` out, **not** the inherited environment (DSH's credentials service reads it first), the project's `.env` or its skill folders, nor the filesystem (`dsh-fs-local` has no sandbox). In the [plugin compatibility](docs/evals/plugin-compatibility.md) matrix (re-measured 2026-10-12, on rutis 0.9) DSH's top 20 go from 3 to 4 usable (`@dfy-plugins/dsh-visualize`); what still blocks the rest is DSH's app — its web UI, model layer, agents and settings, among others. pi unchanged at 12/20. +- yoagent-frontend: `PluginHostBuilder::env(runtime, name, value)` (a runtime not started is an error), `host::DshHome` (`YOAGENT_DSH_HOME`, else a private owner-only directory per run) and `host::rows_from_file`; `coding_agent` / `handoff_agent` take `--dsh-services`. ## yoagent-rutis 0.3.0 (2026-10-12) diff --git a/integrations/yoagent-frontend/CLAUDE.md b/integrations/yoagent-frontend/CLAUDE.md index 55fe867..1973e75 100644 --- a/integrations/yoagent-frontend/CLAUDE.md +++ b/integrations/yoagent-frontend/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code when working in `integrations/yoagent-frontend/`. The r ## Frontend layer (`integrations/yoagent-frontend/`, EXPERIMENTAL, separate crate) -`publish = false`, no API promise; neither yoagent's core nor yoagent-rutis's API depends on it (its one touch outside: the pi adapter's optional dialogs, below). `Session::new(accept_quit) -> (Session, Driver)`: `Driver::run(agent) -> Agent` owns the agent; the `Driver` holds an `Ending` guard, so however it goes (returns, its task panics or is aborted, or it is dropped unrun) an open run gets `RunEnd{outcome: Error}`, the session is marked closed and every frontend gets `Closed`. `Session` (clone; only handles hold the command sender, so dropping them all ends the driver): `connect()` → `Connection { id, messages }` (own unbounded channel per frontend, `Hello` first, then the replay of `Inner.history` (the last `HISTORY` = 5000 `runStart`/`event`/`runEnd`/`notice` broadcasts, recorded in `Inner::broadcast` under `clients` then `history`, so a late frontend gets each message once; kept across a reset; questions not included — Hello carries the open ones) — then `Closed` at once if the session ended — built under the clients lock, nothing dropped; **lock order is `clients` → `ui`**: never take `clients` (`frontends()`, `broadcast`) while holding `ui`), `send(ClientMessage) -> bool` (false + `warn!` once closed; a `UiResponse` resolves its question only if `UiRequest::accepts` the value — confirm: bool, select: an option or null, input: string or null — else it stays open), `is_closed`, `notice(level, msg)`, crate-private `send_to(id, msg)`, `ask(UiRequest, timeout)` / `ask_keyed(.., key)` (no frontend → `default_answer` at once, logged; else broadcast `UiRequest`, first fitting answer wins; a `Close` guard removes the entry however `ask` ends and broadcasts `UiResolved{id, reason: Answered|TimedOut|Withdrawn}`; a key already in use is not taken over (warned) and the guard removes a key only if it maps to its own id), `ask_later(request)` (= `ask_inner(.., timeout None, hold true)`: kept open with no frontend attached (later ones get it in `Hello`), no timeout; the `Ending` guard clears `pending` under `clients` → `ui`, and `ask_inner` returns the default when `is_closed()` under the `ui` lock, so a held question never outlives the session), `withdraw(key)` (unknown key → remembered 60 s, so a withdrawal that overtakes its question returns the default without showing it), `add_ui_plugin(UiPlugin, module) -> Option` (the version; empty name → `None`), `remove_ui_plugin(name, Option)`, `ui_plugin_module`. Protocol (`protocol.rs`, tag `type`, camelCase, enums `#[non_exhaustive]`): in `prompt`/`steer`/`followUp`/`abort`/`reset`/`uiResponse`/`quit`; out `hello{running,uiPlugins,uiRequests}`, `runStart{run,prompt}`, `event{run,event}` (text/thinking deltas merged by `Coalesced`, held ≤30 ms), `runEnd{run,outcome: RunOutcome(completed|aborted|rejected|error),error,stats,totalCostUsd}` (always once per run; no `AgentEnd` → `error` + an error `Notice`; `InputRejected` → `rejected`; Abort/Reset/Quit → `aborted`; else the last assistant message), `uiRequest`/`uiResolved{id,reason}`, `notice{level,message}`, `uiPlugins`, `closed`. `UiPlugin.version` is `skip_deserializing` (set by the session). Prompt mid-run → queued; steer/followUp idle → a prompt; steering/follow-ups the loop never read (they arrived after its last look, e.g. while extensions `finish`) become the next run's prompt unless the run was aborted (a reset clears them); `Reset` mid-run aborts, drops the queue and clears history after `finish`. `services::provide(root, &session)`: host services `frontend` (`connect(client)` sync → `client.receive(msg)` per message in order, raced against the disposer / the caller's `Connection::closed`, a failed `receive` disconnects it; `send` async, `Err` once closed; `addUiPlugin(info, module)` sync → disposer removing that version only, `Err` on an empty name) and `ui` (`frontends()` sync → the attached count; `request(request, timeoutMs?)` → `Value::future`; `key` must be a string, `timeoutMs` null or a finite non-negative number (fractions ok); `withdraw(key)` string only); unit tests drive `invoke` directly. `web::serve(session, addr) -> Served` (private fields; `url()`, `addr()`, `token()`, `stop()`; dropping it stops serving) (axum 0.8, `ws`): `/`, `/app.js`, `/lib.js` (embedded `web/`), `/favicon.ico` 204, `/ui-plugins/{name}` (`.js`), `/ws` **requires `?t=`** (uuid v4; 403 otherwise — WebSockets skip CORS, so without it any page could drive the agent); a frame that is not the protocol → `warn!` + a `Notice` to that frontend; UI plugin modules are trusted code. Page: `web/lib.js` (no DOM; `web/package.json` marks `web/` as ESM — the terminal UI imports it, and rutis's loader would read it as CommonJS: `toolView(details)` (only the six card kinds), `viewTitle`, `diffRows` (LCS; unaligned when old × new line counts exceed 400²), `parseAnswer(request, text)` (the terminal UI's typed answers: `{value}` or `{error}` → asked again, never guessed), `safeUrl` (http(s)), `markdownParts` (https links only), `Questions` FIFO, `runEndLine`), tested by `node --test web/lib.test.js`; questions are a docked, non-modal `` (`show()`, fixed above the composer, `body.asking` pads the log, "1 of N questions waiting") — the page stays usable while one waits; `app.js` handles messages serially (a promise chain), refuses to send on a closed socket or ended session (says so; the prompt stays in the box, a dialog stays open). `host::PluginHost::builder().node(name, dir).share(svc).terminal(name)?.env(runtime, k, v)?.start(root)` (`env`: a runtime process's environment — the examples give the `dsh` runtime `DSH_HOME` = `YOAGENT_DSH_HOME`, else `$TMPDIR/yoagent-dsh-home`, never `~/.dsh`; `host::rows_from_file(path, runtime)` reads row lists such as yoagent-rutis's `plugins/dsh/host-services.json`, which both examples load before the DSH plugins) (`terminal(name)`: that runtime inherits stdin and so stays in the terminal's process group — rutis 0.9 puts every other runtime in a group of its own, where touching the terminal stops it (SIGTTIN/SIGTTOU); `coding_agent` sets it for `ui` in terminal mode, `terminal-ui.ts` reads `process.stdin` when it is a TTY, else `/dev/tty`; CI drives the real terminal mode with `tests/terminal_pty.py` (pty: the screen draws, a typed prompt runs a tool, no process stopped), which the headless `--demo` cannot catch) → `load(rows)` (`Row::new(id, name).runtime(r).config(..).inject(..)`; a `Routed` resolver sends a row to its named runtime, others to the first that resolves; a name already routed to another runtime is refused) waits until rows are `Active` (reconcile counts `Pending` as settled), errors naming those that are not; a failed batch is dropped (rows and routes restored; a failed rollback is logged); `status()`; `unload(id)` (row dropped, its route too when no other row loads that name, reconcile; restored on failure) and `reload(id)` = `RuntimeResolver::invalidate(name)` on every runtime's resolver (kept in `PluginHost.runtimes`) + `Loader::reload(id)` (re-describe → the runner's `fresh()` imports the changed file with a `?rutis-reload=` query → row replaced) + wait until it runs. **Not atomic:** a module that fails to import/describe is refused before anything stops (old version keeps running), but one that fails in `apply` has already replaced the old row, and the loader's rollback re-imports the same (new) file — the plugin stays `Failed` until a later reload succeeds; the error says "running version stays" or "is stopped" (by `is_running(id)`); the example runs `.require_policy()` so a stopped pi policy denies calls. `load` waits only for the rows it added and fails only on `new_failures`, `unload` waits for none — a stopped plugin does not block other changes. **Without `invalidate`, `Loader::reload` is a no-op** for a Node file row (the resolver caches by package version, and a lone file has none) and `Loader::restart` re-applies the already-imported module. Only the plugin's own module is re-imported (not its imports); a `.ts` plugin must sit in a `"type": "module"` package (tsx loads it as CommonJS otherwise, whose cache ignores the query); pi extensions reload with the pi adapter (pi's jiti loader, `moduleCache: false`). `tests/host_test.rs` (temp ESM `.ts` plugin with a `version` tool: edit → new code, unchanged reload restarts, a syntax error refused with the old code kept, an `apply` that throws leaves it stopped (and another plugin still loads), the next good save brings it back, unload removes the tool). **A TS row is Active before its async `apply` finishes** — wait for the handlers you depend on. **A row-level `inject` makes the row wait for every name** (on rutis 0.7/0.8 it stopped a TS row from ever applying; 0.9 applies it — rechecked 2026-10-12); for a service a plugin may go without, re-provide it instead, as `host-ui.ts` does. Plugins (`plugins/`, own `package.json`: rutis 0.9.1, pi-tui 1.1.0): `terminal-ui.ts` (pi-tui on `frontend`; `/dev/tty` keyboard; questions answered in the editor, a stale prefill cleared when its question closes; `receive` never throws (shown instead); a failed quit goes to stderr; `demo` headless prints `[header, log, status]` and declines questions), `search-links.ts` (UI plugin: `renderTool` links for search tools, `mountPanel` quick prompts), `pi-extensions/confirm-dangerous.ts` (a pi permission gate using `ctx.ui.select`). pi dialogs: rutis shows a **host** service only to plugins that inject it and starts a plugin only once all injected services exist, so `yoagent-rutis/plugins/pi/host-ui.ts` injects `ui` and re-provides it in the pi runtime as `pi-host-ui`; the adapter looks that up per pi context (`lookupUi`; missing → print mode, said once at debug) → RPC mode (`mode: 'rpc'`, `hasUI: true`, `select`/`confirm`/`input`/`editor`/`notify` forwarded; failures → print-mode answers); each question keyed `pi-` and withdrawn on its signal's abort (a failed withdraw is reported at warn); without it, print mode unchanged. `examples/minimal_browser.rs`: the smallest use — `Session::new(false)` + `web::serve` + an agent with read-only tools (`list_files`, `read_file`, `search`) sandboxed to the cwd (`with_allowed_paths`; the built-ins allow every path by default), no plugins or Node; scripted, or `--live` DeepSeek; tried live in Chrome. `examples/coding_agent.rs`: runtimes `ui`/`pi`/`dsh`, terminal (default) or `--web [ADDR]` (8787), `--demo`, `--live` (`DEEPSEEK_API_KEY`), `--no-dsh`, `--watch` (a task owning the `PluginHost` polls the plugin files every 500 ms by mtime + size — row files, the pi extension → `pi`, terminal-ui in terminal mode — settles 200 ms (files changed meanwhile are reloaded too), `reload`s the owning row and sends every frontend a `Notice` — Info, Warning when the old version stays, Error when the plugin is stopped; tried live in Chrome: an edited pi extension's new question text, a broken save refused); waits for `pi-extensions`/`dsh-tools` handlers; `tracing-subscriber` (stderr with `--web`/`--demo`, else `$TMPDIR/yoagent-coding-agent.log`); a shutdown error never hides the run's. Tool cards: a result's `details.view` (DSH's card vocabulary) is drawn by `app.js` `card()` (terminal/diff/search/read/web/generic; `CARD_LINES` 200 per block, ≤20 web sources; a generic card with no text draws nothing; a card that throws → "(this tool's card could not be drawn)") unless a UI plugin draws that tool; the line takes `viewTitle` (browser: only then; terminal UI: always). `UiRequest::Select { title, message, options, multiple }` (answer: a list of distinct options with `multiple`; a frontend that cannot offer several must leave it unanswered), `Input` has `message` too; `ask_keyed` asks `request.normalized()` (a select's options each once). Tests: `session_test` (19, a `Gate` tool keeps runs in progress), `web_test` (4, tokio-tungstenite), `pi_dialog_test` (real pi runtime: Allow runs `bash`, Block stops it; skipped without `plugins/pi/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`), `dsh_dialog_test` (real DSH runtime: approval yes/no, `ask_user_question`, `details.view`; stopping a run withdraws its open approval (`UiResolved` withdrawn, `RunEnd` aborted, tool not run); skipped without `plugins/dsh/node_modules/@deepseek-ai/dsh-tool-ask-user` — re-run `npm ci` after a lock change), unit tests (`Coalesced`, services, `UiRequest::accepts`); CI job `frontend-experimental` (fmt, clippy, docs, tests with runtimes required, `node --test`, headless example asserting the pi question reached the terminal UI and `bash ✗`). Tried live in Chrome (DeepSeek + DSH search, links rendered by the UI plugin). +`publish = false`, no API promise; neither yoagent's core nor yoagent-rutis's API depends on it (its one touch outside: the pi adapter's optional dialogs, below). `Session::new(accept_quit) -> (Session, Driver)`: `Driver::run(agent) -> Agent` owns the agent; the `Driver` holds an `Ending` guard, so however it goes (returns, its task panics or is aborted, or it is dropped unrun) an open run gets `RunEnd{outcome: Error}`, the session is marked closed and every frontend gets `Closed`. `Session` (clone; only handles hold the command sender, so dropping them all ends the driver): `connect()` → `Connection { id, messages }` (own unbounded channel per frontend, `Hello` first, then the replay of `Inner.history` (the last `HISTORY` = 5000 `runStart`/`event`/`runEnd`/`notice` broadcasts, recorded in `Inner::broadcast` under `clients` then `history`, so a late frontend gets each message once; kept across a reset; questions not included — Hello carries the open ones) — then `Closed` at once if the session ended — built under the clients lock, nothing dropped; **lock order is `clients` → `ui`**: never take `clients` (`frontends()`, `broadcast`) while holding `ui`), `send(ClientMessage) -> bool` (false + `warn!` once closed; a `UiResponse` resolves its question only if `UiRequest::accepts` the value — confirm: bool, select: an option or null, input: string or null — else it stays open), `is_closed`, `notice(level, msg)`, crate-private `send_to(id, msg)`, `ask(UiRequest, timeout)` / `ask_keyed(.., key)` (no frontend → `default_answer` at once, logged; else broadcast `UiRequest`, first fitting answer wins; a `Close` guard removes the entry however `ask` ends and broadcasts `UiResolved{id, reason: Answered|TimedOut|Withdrawn}`; a key already in use is not taken over (warned) and the guard removes a key only if it maps to its own id), `ask_later(request)` (= `ask_inner(.., timeout None, hold true)`: kept open with no frontend attached (later ones get it in `Hello`), no timeout; the `Ending` guard clears `pending` under `clients` → `ui`, and `ask_inner` returns the default when `is_closed()` under the `ui` lock, so a held question never outlives the session), `withdraw(key)` (unknown key → remembered 60 s, so a withdrawal that overtakes its question returns the default without showing it), `add_ui_plugin(UiPlugin, module) -> Option` (the version; empty name → `None`), `remove_ui_plugin(name, Option)`, `ui_plugin_module`. Protocol (`protocol.rs`, tag `type`, camelCase, enums `#[non_exhaustive]`): in `prompt`/`steer`/`followUp`/`abort`/`reset`/`uiResponse`/`quit`; out `hello{running,uiPlugins,uiRequests}`, `runStart{run,prompt}`, `event{run,event}` (text/thinking deltas merged by `Coalesced`, held ≤30 ms), `runEnd{run,outcome: RunOutcome(completed|aborted|rejected|error),error,stats,totalCostUsd}` (always once per run; no `AgentEnd` → `error` + an error `Notice`; `InputRejected` → `rejected`; Abort/Reset/Quit → `aborted`; else the last assistant message), `uiRequest`/`uiResolved{id,reason}`, `notice{level,message}`, `uiPlugins`, `closed`. `UiPlugin.version` is `skip_deserializing` (set by the session). Prompt mid-run → queued; steer/followUp idle → a prompt; steering/follow-ups the loop never read (they arrived after its last look, e.g. while extensions `finish`) become the next run's prompt unless the run was aborted (a reset clears them); `Reset` mid-run aborts, drops the queue and clears history after `finish`. `services::provide(root, &session)`: host services `frontend` (`connect(client)` sync → `client.receive(msg)` per message in order, raced against the disposer / the caller's `Connection::closed`, a failed `receive` disconnects it; `send` async, `Err` once closed; `addUiPlugin(info, module)` sync → disposer removing that version only, `Err` on an empty name) and `ui` (`frontends()` sync → the attached count; `request(request, timeoutMs?)` → `Value::future`; `key` must be a string, `timeoutMs` null or a finite non-negative number (fractions ok); `withdraw(key)` string only); unit tests drive `invoke` directly. `web::serve(session, addr) -> Served` (private fields; `url()`, `addr()`, `token()`, `stop()`; dropping it stops serving) (axum 0.8, `ws`): `/`, `/app.js`, `/lib.js` (embedded `web/`), `/favicon.ico` 204, `/ui-plugins/{name}` (`.js`), `/ws` **requires `?t=`** (uuid v4; 403 otherwise — WebSockets skip CORS, so without it any page could drive the agent); a frame that is not the protocol → `warn!` + a `Notice` to that frontend; UI plugin modules are trusted code. Page: `web/lib.js` (no DOM; `web/package.json` marks `web/` as ESM — the terminal UI imports it, and rutis's loader would read it as CommonJS: `toolView(details)` (only the six card kinds), `viewTitle`, `diffRows` (LCS; unaligned when old × new line counts exceed 400²), `parseAnswer(request, text)` (the terminal UI's typed answers: `{value}` or `{error}` → asked again, never guessed), `safeUrl` (http(s)), `markdownParts` (https links only), `Questions` FIFO, `runEndLine`), tested by `node --test web/lib.test.js`; questions are a docked, non-modal `` (`show()`, fixed above the composer, `body.asking` pads the log, "1 of N questions waiting") — the page stays usable while one waits; `app.js` handles messages serially (a promise chain), refuses to send on a closed socket or ended session (says so; the prompt stays in the box, a dialog stays open). `host::PluginHost::builder().node(name, dir).share(svc).terminal(name)?.env(runtime, k, v)?.start(root)` (`env`: added to a runtime process's inherited environment; an entry for a runtime not started fails `start`; `host::DshHome::new()` = `YOAGENT_DSH_HOME` (non-empty; an empty one would mean `~/.dsh` to DSH) else a private 0700 temp dir removed on drop, `env()` = `DSH_HOME` (+ `DSH_AGENTS_HOME` inside a private one); `host::rows_from_file(path, runtime)` reads row lists such as yoagent-rutis's `plugins/dsh/host-services.json`; `coding_agent` / `handoff_agent` load those only with `--dsh-services` — their own DSH plugins need none of them; tests: host_test `env_reaches_only_its_runtime_and_an_unknown_one_is_an_error`, unit tests for `rows_from_file` and `DshHome`) (`terminal(name)`: that runtime inherits stdin and so stays in the terminal's process group — rutis 0.9 puts every other runtime in a group of its own, where touching the terminal stops it (SIGTTIN/SIGTTOU); `coding_agent` sets it for `ui` in terminal mode, `terminal-ui.ts` reads `process.stdin` when it is a TTY, else `/dev/tty`; CI drives the real terminal mode with `tests/terminal_pty.py` (pty: the screen draws, a typed prompt runs a tool, no process stopped), which the headless `--demo` cannot catch) → `load(rows)` (`Row::new(id, name).runtime(r).config(..).inject(..)`; a `Routed` resolver sends a row to its named runtime, others to the first that resolves; a name already routed to another runtime is refused) waits until rows are `Active` (reconcile counts `Pending` as settled), errors naming those that are not; a failed batch is dropped (rows and routes restored; a failed rollback is logged); `status()`; `unload(id)` (row dropped, its route too when no other row loads that name, reconcile; restored on failure) and `reload(id)` = `RuntimeResolver::invalidate(name)` on every runtime's resolver (kept in `PluginHost.runtimes`) + `Loader::reload(id)` (re-describe → the runner's `fresh()` imports the changed file with a `?rutis-reload=` query → row replaced) + wait until it runs. **Not atomic:** a module that fails to import/describe is refused before anything stops (old version keeps running), but one that fails in `apply` has already replaced the old row, and the loader's rollback re-imports the same (new) file — the plugin stays `Failed` until a later reload succeeds; the error says "running version stays" or "is stopped" (by `is_running(id)`); the example runs `.require_policy()` so a stopped pi policy denies calls. `load` waits only for the rows it added and fails only on `new_failures`, `unload` waits for none — a stopped plugin does not block other changes. **Without `invalidate`, `Loader::reload` is a no-op** for a Node file row (the resolver caches by package version, and a lone file has none) and `Loader::restart` re-applies the already-imported module. Only the plugin's own module is re-imported (not its imports); a `.ts` plugin must sit in a `"type": "module"` package (tsx loads it as CommonJS otherwise, whose cache ignores the query); pi extensions reload with the pi adapter (pi's jiti loader, `moduleCache: false`). `tests/host_test.rs` (temp ESM `.ts` plugin with a `version` tool: edit → new code, unchanged reload restarts, a syntax error refused with the old code kept, an `apply` that throws leaves it stopped (and another plugin still loads), the next good save brings it back, unload removes the tool). **A TS row is Active before its async `apply` finishes** — wait for the handlers you depend on. **A row-level `inject` makes the row wait for every name** (on rutis 0.7/0.8 it stopped a TS row from ever applying; 0.9 applies it — rechecked 2026-10-12); for a service a plugin may go without, re-provide it instead, as `host-ui.ts` does. Plugins (`plugins/`, own `package.json`: rutis 0.9.1, pi-tui 1.1.0): `terminal-ui.ts` (pi-tui on `frontend`; `/dev/tty` keyboard; questions answered in the editor, a stale prefill cleared when its question closes; `receive` never throws (shown instead); a failed quit goes to stderr; `demo` headless prints `[header, log, status]` and declines questions), `search-links.ts` (UI plugin: `renderTool` links for search tools, `mountPanel` quick prompts), `pi-extensions/confirm-dangerous.ts` (a pi permission gate using `ctx.ui.select`). pi dialogs: rutis shows a **host** service only to plugins that inject it and starts a plugin only once all injected services exist, so `yoagent-rutis/plugins/pi/host-ui.ts` injects `ui` and re-provides it in the pi runtime as `pi-host-ui`; the adapter looks that up per pi context (`lookupUi`; missing → print mode, said once at debug) → RPC mode (`mode: 'rpc'`, `hasUI: true`, `select`/`confirm`/`input`/`editor`/`notify` forwarded; failures → print-mode answers); each question keyed `pi-` and withdrawn on its signal's abort (a failed withdraw is reported at warn); without it, print mode unchanged. `examples/minimal_browser.rs`: the smallest use — `Session::new(false)` + `web::serve` + an agent with read-only tools (`list_files`, `read_file`, `search`) sandboxed to the cwd (`with_allowed_paths`; the built-ins allow every path by default), no plugins or Node; scripted, or `--live` DeepSeek; tried live in Chrome. `examples/coding_agent.rs`: runtimes `ui`/`pi`/`dsh`, terminal (default) or `--web [ADDR]` (8787), `--demo`, `--live` (`DEEPSEEK_API_KEY`), `--no-dsh`, `--watch` (a task owning the `PluginHost` polls the plugin files every 500 ms by mtime + size — row files, the pi extension → `pi`, terminal-ui in terminal mode — settles 200 ms (files changed meanwhile are reloaded too), `reload`s the owning row and sends every frontend a `Notice` — Info, Warning when the old version stays, Error when the plugin is stopped; tried live in Chrome: an edited pi extension's new question text, a broken save refused); waits for `pi-extensions`/`dsh-tools` handlers; `tracing-subscriber` (stderr with `--web`/`--demo`, else `$TMPDIR/yoagent-coding-agent.log`); a shutdown error never hides the run's. Tool cards: a result's `details.view` (DSH's card vocabulary) is drawn by `app.js` `card()` (terminal/diff/search/read/web/generic; `CARD_LINES` 200 per block, ≤20 web sources; a generic card with no text draws nothing; a card that throws → "(this tool's card could not be drawn)") unless a UI plugin draws that tool; the line takes `viewTitle` (browser: only then; terminal UI: always). `UiRequest::Select { title, message, options, multiple }` (answer: a list of distinct options with `multiple`; a frontend that cannot offer several must leave it unanswered), `Input` has `message` too; `ask_keyed` asks `request.normalized()` (a select's options each once). Tests: `session_test` (19, a `Gate` tool keeps runs in progress), `web_test` (4, tokio-tungstenite), `pi_dialog_test` (real pi runtime: Allow runs `bash`, Block stops it; skipped without `plugins/pi/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`), `dsh_dialog_test` (real DSH runtime: approval yes/no, `ask_user_question`, `details.view`; stopping a run withdraws its open approval (`UiResolved` withdrawn, `RunEnd` aborted, tool not run); skipped without `plugins/dsh/node_modules/@deepseek-ai/dsh-tool-ask-user` — re-run `npm ci` after a lock change), unit tests (`Coalesced`, services, `UiRequest::accepts`); CI job `frontend-experimental` (fmt, clippy, docs, tests with runtimes required, `node --test`, headless example asserting the pi question reached the terminal UI and `bash ✗`). Tried live in Chrome (DeepSeek + DSH search, links rendered by the UI plugin). `scripts/try-coding-agent.sh` (repo root): checks cargo / Node 24+ / npm, `npm ci` in the three plugin dirs when `node_modules/.package-lock.json` is missing or older than the lockfile, then runs `coding_agent` with `--web` unless `--terminal` (or `--web`/`--demo` given); `--setup-only`; `--live` needs `DEEPSEEK_API_KEY`; empty-array expansions are bash-3.2 safe. diff --git a/integrations/yoagent-frontend/Cargo.toml b/integrations/yoagent-frontend/Cargo.toml index 1071f33..86e4299 100644 --- a/integrations/yoagent-frontend/Cargo.toml +++ b/integrations/yoagent-frontend/Cargo.toml @@ -13,6 +13,8 @@ publish = false exclude = ["plugins/node_modules"] [dependencies] +# DshHome: a private, per-run home for DSH's own services. +tempfile = "3" yoagent = { path = "../.." } yoagent-rutis = { path = "../yoagent-rutis", features = ["node"] } rutis = "0.9" diff --git a/integrations/yoagent-frontend/examples/coding_agent.rs b/integrations/yoagent-frontend/examples/coding_agent.rs index 0195279..773e724 100644 --- a/integrations/yoagent-frontend/examples/coding_agent.rs +++ b/integrations/yoagent-frontend/examples/coding_agent.rs @@ -13,6 +13,9 @@ //! (a question gets "no", as from a user who walked away). CI runs it. //! - `--live`: DeepSeek (`DEEPSEEK_API_KEY`); otherwise a scripted model. //! - `--no-dsh`: skip DSH (web search, `ask_user_question` and its dialogs). +//! - `--dsh-services`: also load DSH's own host services (files, attachments, +//! credentials, storage, session persistence, skills) for DSH plugins that +//! need them, in a DSH home of their own (`YOAGENT_DSH_HOME`, else private). //! - `--watch`: reload a plugin when its file changes (polled twice a second): //! the UI plugins, the adapters, the pi extension; each reload is told to //! the frontends. New code reaches the next run. A version that does not @@ -39,7 +42,7 @@ use yoagent::provider::mock::{MockResponse, MockToolCall}; use yoagent::provider::{MockProvider, ModelConfig}; use yoagent::tools::default_tools; use yoagent::Agent; -use yoagent_frontend::host::{rows_from_file, PluginHost, Row}; +use yoagent_frontend::host::{rows_from_file, DshHome, PluginHost, Row}; use yoagent_frontend::{services, session, web, NoticeLevel, Session}; use yoagent_rutis::RutisBridge; @@ -57,12 +60,15 @@ struct Options { web: Option, demo: Option, dsh: bool, + /// DSH's own host services (`--dsh-services`), for DSH plugins that need them. + dsh_services: bool, watch: bool, } fn main() -> Result<(), BoxError> { let (mut live, mut web, mut demo, mut watch) = (false, None, None, false); let mut dsh = rutis_plugins().join("dsh/node_modules").exists(); + let mut dsh_services = false; let mut args = std::env::args().skip(1).peekable(); while let Some(arg) = args.next() { match arg.as_str() { @@ -76,6 +82,7 @@ fn main() -> Result<(), BoxError> { } "--demo" => demo = Some(args.next().ok_or("--demo needs a prompt")?), "--no-dsh" => dsh = false, + "--dsh-services" => dsh_services = true, "--watch" => watch = true, other => return Err(format!("unknown argument {other}").into()), } @@ -103,6 +110,7 @@ fn main() -> Result<(), BoxError> { web, demo, dsh, + dsh_services, watch, }; tokio::runtime::Builder::new_multi_thread() @@ -161,11 +169,17 @@ async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { let mut builder = PluginHost::builder() .node("ui", here().join("plugins")) .node("pi", rutis_plugins().join("pi")); + // DSH's own host services, on request: in a home of their own, never + // the user's `~/.dsh` (see `DshHome` for what that does not cover). + let dsh_home = match options.dsh && options.dsh_services { + true => Some(DshHome::new()?), + false => None, + }; if options.dsh { - builder = - builder - .node("dsh", rutis_plugins().join("dsh")) - .env("dsh", "DSH_HOME", dsh_home()); + builder = builder.node("dsh", rutis_plugins().join("dsh")); + for (name, value) in dsh_home.iter().flat_map(DshHome::env) { + builder = builder.env("dsh", name, value); + } } if options.web.is_none() && options.demo.is_none() { // The terminal UI reads the keyboard: its runtime keeps the terminal. @@ -203,11 +217,14 @@ async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { .runtime("ui"), ]; if options.dsh { - // The services a headless DSH host runs, for plugins that need them. - rows.extend(rows_from_file( - &rutis_plugins().join("dsh/host-services.json"), - "dsh", - )?); + // The services a headless DSH host runs, for plugins that need them + // (none of the rows below does). + if options.dsh_services { + rows.extend(rows_from_file( + &rutis_plugins().join("dsh/host-services.json"), + "dsh", + )?); + } let dsh = |id: &str, name: &str| Row::new(id, name).runtime("dsh"); rows.extend([ dsh("dsh-web", "@deepseek-ai/dsh-web"), @@ -422,16 +439,3 @@ fn watch( } }) } - -/// DSH's home for its own services (fs, attachments, credentials, storage, -/// session persistence, skills): `YOAGENT_DSH_HOME`, else a directory of -/// yoagent's own under the temp dir — never the user's `~/.dsh`, whose -/// credentials and skills DSH plugins would otherwise see. Point -/// `YOAGENT_DSH_HOME` at `~/.dsh` to share them on purpose. -fn dsh_home() -> std::path::PathBuf { - let home = std::env::var_os("YOAGENT_DSH_HOME") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::env::temp_dir().join("yoagent-dsh-home")); - let _ = std::fs::create_dir_all(&home); - home -} diff --git a/integrations/yoagent-frontend/examples/handoff_agent.rs b/integrations/yoagent-frontend/examples/handoff_agent.rs index 97c3ff4..29f6470 100644 --- a/integrations/yoagent-frontend/examples/handoff_agent.rs +++ b/integrations/yoagent-frontend/examples/handoff_agent.rs @@ -29,7 +29,7 @@ //! (`cargo run` needs `--manifest-path path/to/yoagent/integrations/yoagent-frontend/Cargo.toml` //! when started from the project directory.) //! -//! `--web ADDR` (default 127.0.0.1:8787), `--no-dsh`, `--max-minutes N` +//! `--web ADDR` (default 127.0.0.1:8787), `--no-dsh`, `--dsh-services` (DSH's own host services, for DSH plugins that need them, in a DSH home of their own), `--max-minutes N` //! (30) and `--max-tokens N` (4M) per run, `--budget-usd X` (2; priced //! models only). Without a task, give it //! one in the page. `--phone` serves on every interface: anyone on the @@ -57,7 +57,7 @@ use yoagent::{ Agent, AgentEvent, AgentMessage, AgentTool, Content, Message, ToolContext, ToolError, ToolResult, }; -use yoagent_frontend::host::{rows_from_file, PluginHost, Row}; +use yoagent_frontend::host::{rows_from_file, DshHome, PluginHost, Row}; use yoagent_frontend::{ services, session, web, ClientMessage, NoticeLevel, ServerMessage, Session, UiRequest, }; @@ -108,6 +108,8 @@ struct Options { phone: bool, demo: bool, dsh: bool, + /// DSH's own host services (`--dsh-services`), for DSH plugins that need them. + dsh_services: bool, task: Option, max_minutes: u64, max_tokens: usize, @@ -123,6 +125,7 @@ fn main() -> Result<(), BoxError> { phone: false, demo: false, dsh: rutis_plugins().join("dsh/node_modules").exists(), + dsh_services: false, task: None, max_minutes: 30, // Research reads a lot: yoagent's default (1M per run, the whole @@ -139,6 +142,7 @@ fn main() -> Result<(), BoxError> { "--live" => live = true, "--demo" => options.demo = true, "--no-dsh" => options.dsh = false, + "--dsh-services" => options.dsh_services = true, "--phone" => { options.phone = true; options.addr = "0.0.0.0:8787".parse()?; @@ -218,11 +222,17 @@ async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { let mut builder = PluginHost::builder() .node("ui", here().join("plugins")) .node("pi", rutis_plugins().join("pi")); + // DSH's own host services, on request: in a home of their own, never + // the user's `~/.dsh` (see `DshHome` for what that does not cover). + let dsh_home = match options.dsh && options.dsh_services { + true => Some(DshHome::new()?), + false => None, + }; if options.dsh { - builder = - builder - .node("dsh", rutis_plugins().join("dsh")) - .env("dsh", "DSH_HOME", dsh_home()); + builder = builder.node("dsh", rutis_plugins().join("dsh")); + for (name, value) in dsh_home.iter().flat_map(DshHome::env) { + builder = builder.env("dsh", name, value); + } } let mut host = builder .share("yoagent") @@ -254,11 +264,14 @@ async fn run(root: &Ctx, options: Options) -> Result<(), BoxError> { Row::new("todo-plan", here().join("plugins/todo-plan.ts").display()).runtime("ui"), ]; if options.dsh { - // The services a headless DSH host runs, for plugins that need them. - rows.extend(rows_from_file( - &rutis_plugins().join("dsh/host-services.json"), - "dsh", - )?); + // The services a headless DSH host runs, for plugins that need them + // (none of the rows below does). + if options.dsh_services { + rows.extend(rows_from_file( + &rutis_plugins().join("dsh/host-services.json"), + "dsh", + )?); + } let dsh = |id: &str, name: &str| Row::new(id, name).runtime("dsh"); rows.extend([ dsh("dsh-web", "@deepseek-ai/dsh-web"), @@ -878,16 +891,3 @@ async fn demo( println!("ok: asked without waiting, answered later, wrote notes.md, two reports"); Ok(()) } - -/// DSH's home for its own services (fs, attachments, credentials, storage, -/// session persistence, skills): `YOAGENT_DSH_HOME`, else a directory of -/// yoagent's own under the temp dir — never the user's `~/.dsh`, whose -/// credentials and skills DSH plugins would otherwise see. Point -/// `YOAGENT_DSH_HOME` at `~/.dsh` to share them on purpose. -fn dsh_home() -> std::path::PathBuf { - let home = std::env::var_os("YOAGENT_DSH_HOME") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| std::env::temp_dir().join("yoagent-dsh-home")); - let _ = std::fs::create_dir_all(&home); - home -} diff --git a/integrations/yoagent-frontend/src/host.rs b/integrations/yoagent-frontend/src/host.rs index f9ff114..443e808 100644 --- a/integrations/yoagent-frontend/src/host.rs +++ b/integrations/yoagent-frontend/src/host.rs @@ -92,9 +92,77 @@ impl Row { } } +/// Where DSH's own services (`yoagent-rutis/plugins/dsh/host-services.json`) +/// keep their data: the credentials file, attachments, skills. They resolve +/// it from `DSH_HOME`, else `~/.dsh` — the user's real DSH home, with its +/// stored keys — and skills also from `DSH_AGENTS_HOME`, else `~/.agents`. +/// +/// [`DshHome::new`] picks a home that is not the user's: `YOAGENT_DSH_HOME` +/// when set (non-empty), else a private directory created for this process +/// (owner-only, removed when the `DshHome` drops). [`DshHome::env`] is what +/// to give the DSH runtime ([`PluginHostBuilder::env`]). +/// +/// What this does not keep from DSH plugins: the runtime inherits the host's +/// environment (an API key there is readable, and DSH's credentials service +/// ranks it first), the project's `.env` and skill folders (DSH reads them +/// from the working directory), and the filesystem (`dsh-fs-local` has no +/// sandbox). A guard against reusing `~/.dsh` by accident, not a sandbox. +pub struct DshHome { + path: PathBuf, + /// Set when the directory is ours to remove. + _private: Option, +} + +impl DshHome { + /// `YOAGENT_DSH_HOME` (created if missing), else a new private directory. + /// An empty `YOAGENT_DSH_HOME` counts as unset: DSH reads an empty + /// `DSH_HOME` as `~/.dsh`. + pub fn new() -> std::io::Result { + match std::env::var_os("YOAGENT_DSH_HOME") + .filter(|v| !v.to_string_lossy().trim().is_empty()) + { + Some(path) => { + let path = PathBuf::from(path); + std::fs::create_dir_all(&path)?; + Ok(DshHome { + path, + _private: None, + }) + } + None => { + let mut builder = tempfile::Builder::new(); + builder.prefix("yoagent-dsh-home-"); + // Owner-only: what DSH stores here (credentials) is ours alone. + #[cfg(unix)] + builder.permissions(std::os::unix::fs::PermissionsExt::from_mode(0o700)); + let dir = builder.tempdir()?; + Ok(DshHome { + path: dir.path().to_path_buf(), + _private: Some(dir), + }) + } + } + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// `DSH_HOME`, and — for a private home — `DSH_AGENTS_HOME` under it, so + /// `~/.agents` skills are not read either. A `YOAGENT_DSH_HOME` the user + /// chose (`~/.dsh`, to share their keys on purpose) leaves skills alone. + pub fn env(&self) -> Vec<(&'static str, PathBuf)> { + let mut env = vec![("DSH_HOME", self.path.clone())]; + if self._private.is_some() { + env.push(("DSH_AGENTS_HOME", self.path.join("agents"))); + } + env + } +} + /// The rows listed in the JSON file `path` (`[{ "id", "name", "config"? }]`, /// such as `yoagent-rutis/plugins/dsh/host-services.json`), each routed to -/// the runtime `runtime`. +/// the runtime `runtime`. Other keys are ignored. pub fn rows_from_file(path: &Path, runtime: &str) -> Result, BoxError> { #[derive(serde::Deserialize)] struct Listed { @@ -145,8 +213,9 @@ impl PluginHostBuilder { } /// Set the environment variable `name` for the runtime `runtime`'s - /// process (say DSH's `DSH_HOME`, where DSH's own services keep their - /// data and read credentials). + /// process, on top of the environment it inherits (say DSH's `DSH_HOME`: + /// see [`DshHome`]). A runtime this builder does not start is an error + /// at [`start`](Self::start). pub fn env( mut self, runtime: impl Into, @@ -165,6 +234,17 @@ impl PluginHostBuilder { /// Start the runtimes and the loader on `root`. pub async fn start(self, root: &Ctx) -> Result { + if let Some((runtime, name, _)) = self + .env + .iter() + .find(|(runtime, ..)| !self.runtimes.iter().any(|(r, _)| r == runtime)) + { + return Err(format!( + "environment variable {} set for runtime `{runtime}`, which is not started", + name.to_string_lossy() + ) + .into()); + } let mut catalog = ServiceCatalog::new(); for service in &self.shared { catalog.register_shared(service); @@ -480,3 +560,72 @@ impl Resolver for Routed { }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rows_from_file_reads_ids_names_and_configs_and_names_the_file_on_errors() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("rows.json"); + std::fs::write( + &file, + r#"[{"id": "a", "name": "pkg-a", "config": {"x": 1}}, {"id": "b", "name": "pkg-b", "note": "ignored"}]"#, + ) + .unwrap(); + let rows = rows_from_file(&file, "dsh").unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!( + ( + rows[0].id.as_str(), + rows[0].name.as_str(), + rows[0].runtime.as_deref() + ), + ("a", "pkg-a", Some("dsh")) + ); + assert_eq!(rows[0].config, json!({"x": 1})); + assert_eq!(rows[1].config, json!({})); + + std::fs::write(&file, "[{").unwrap(); + let bad = rows_from_file(&file, "dsh").unwrap_err().to_string(); + assert!(bad.contains("rows.json"), "{bad}"); + let missing = rows_from_file(&dir.path().join("none.json"), "dsh") + .unwrap_err() + .to_string(); + assert!(missing.contains("none.json"), "{missing}"); + } + + #[test] + fn the_shared_dsh_host_services_list_parses() { + let file = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../yoagent-rutis/plugins/dsh/host-services.json"); + let rows = rows_from_file(&file, "dsh").unwrap(); + assert_eq!(rows.len(), 7); + assert!(rows + .iter() + .all(|row| row.name.starts_with("@deepseek-ai/dsh-"))); + } + + #[test] + fn a_private_dsh_home_is_removed_and_points_skills_inside_it() { + // Not YOAGENT_DSH_HOME: that is the user's choice, tested by hand. + if std::env::var_os("YOAGENT_DSH_HOME").is_some() { + return; + } + let home = DshHome::new().unwrap(); + let path = home.path().to_path_buf(); + assert!(path.is_dir()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "owner-only"); + } + let env = home.env(); + assert_eq!(env[0], ("DSH_HOME", path.clone())); + assert_eq!(env[1], ("DSH_AGENTS_HOME", path.join("agents"))); + drop(home); + assert!(!path.exists()); + } +} diff --git a/integrations/yoagent-frontend/tests/host_test.rs b/integrations/yoagent-frontend/tests/host_test.rs index 9902e47..025bf64 100644 --- a/integrations/yoagent-frontend/tests/host_test.rs +++ b/integrations/yoagent-frontend/tests/host_test.rs @@ -297,3 +297,72 @@ async fn a_plugin_that_disposes_itself_is_not_running() { .unwrap_or_else(|_| panic!("the row settles as ended: {:?}", host.status())); root.shutdown().await.unwrap(); } + +/// `PluginHostBuilder::env` reaches its own runtime's process only, and a +/// runtime that is not started is an error, not silently ignored. +#[tokio::test(flavor = "multi_thread")] +async fn env_reaches_only_its_runtime_and_an_unknown_one_is_an_error() { + if !runtime_installed() { + return; + } + let root = Ctx::root().unwrap(); + let wrong = PluginHost::builder() + .node("ui", plugins()) + .env("iu", "YOAGENT_ENV_TEST", "1") + .start(&root) + .await; + assert!( + matches!(&wrong, Err(e) if e.to_string().contains("`iu`")), + "a typo must not pass" + ); + + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("package.json"), r#"{ "type": "module" }"#).unwrap(); + let writer = |name: &str| { + let file = dir.path().join(format!("{name}.mjs")); + let out = dir.path().join(format!("{name}.txt")); + std::fs::write( + &file, + format!( + "import {{ writeFileSync }} from 'node:fs'\n\ + export const name = '{name}'\n\ + export function apply() {{ writeFileSync({:?}, String(process.env.YOAGENT_ENV_TEST)) }}\n", + out.display().to_string() + ), + ) + .unwrap(); + (file, out) + }; + let (a, a_out) = writer("a"); + let (b, b_out) = writer("b"); + let mut host = PluginHost::builder() + .node("one", plugins()) + .node("two", plugins()) + .env("one", "YOAGENT_ENV_TEST", "1") + .start(&root) + .await + .unwrap(); + host.load([ + Row::new("a", a.display()).runtime("one"), + Row::new("b", b.display()).runtime("two"), + ]) + .await + .unwrap(); + assert_eq!(written(&a_out).await, "1"); + assert_eq!(written(&b_out).await, "undefined"); + root.shutdown().await.unwrap(); +} + +/// The text of `path` once a plugin wrote it. +async fn written(path: &Path) -> String { + tokio::time::timeout(WAIT, async { + loop { + if let Ok(text) = std::fs::read_to_string(path) { + return text; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("the plugin wrote") +} diff --git a/integrations/yoagent-rutis/CLAUDE.md b/integrations/yoagent-rutis/CLAUDE.md index 8e745ad..e353061 100644 --- a/integrations/yoagent-rutis/CLAUDE.md +++ b/integrations/yoagent-rutis/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code when working in `integrations/yoagent-rutis/`. The repo ## rutis bridge (`integrations/yoagent-rutis/`, separate crate) -Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.9"` (0.x caret = 0.9.x; since 0.8 the packages in rutis's release train share one version — move the host and the npm/PyPI runtimes together: a 0.9 host sends call origins a 0.8 runtime rejects; rutis 0.9 needs Rust 1.88, so `rust-version` is 1.88 (yoagent's own stays 1.86); any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Bun / Python plugins** (`languages.rs`, features `node`/`bun`/`python`/`websocket` → optional `rutis-bridge = "0.9"`; `bun`: Bun 1.4+ runs the same TS plugins (`@arcships/rutis-bun` 0.9.1 in `plugins/package.json`, `LocalRuntime::bun(package, project).interpreter(exe)` + `RuntimeResolver::modules` → rows `bun:`); `languages_test` runs `every_hook` / `signal_aborts` on Node and on Bun (`bun_runtime()`: `YOAGENT_RUTIS_BUN` or `bun` on PATH, 1.4+; the rutis-loader dev-dependency enables `bun`); CI installs it with `oven-sh/setup-bun` 1.4; `go`: Go plugins via the rutis Go SDK (`plugins/go/` — own `go.mod` (`go 1.24`, `github.com/arcships/rutis/go/rutis` v0.9.1) — registers `go-policy`: a `before_tool` denying `bash`, `slow_gate` waiting on its call `ctx.Done()`, a `go_echo` tool; the handler is a `map[string]any` of funcs (functions cross by reference; the `signal` field decodes as nil; the call's context also ends when the hook returns (Go's `defer cancel`), so "never cancelled after completing" is not observable from Go; the host's cancel cancels the call's context)); `tests/go_test.rs` (`required-features = ["go"]`) builds it with `go build`, runs it through `GoResolver` + `GoRuntimes` (rows `go:go-policy`), checks the tool + an `after_tool` redaction, that Go's denial / returned error / malformed answer each deny, that a policy timeout and a run cancel cancel Go's context, and that unloading the row removes the handler (disposer `Effect`), after which `bash` runs; a failed `go build` with Go present fails, never skips (Go 1.24+ or `SKIPPED:`); CI `actions/setup-go` 1.24, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.9 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (per image; rutis 0.9 closes a channel on any message over 16 MiB — local `MAX_MESSAGE` and websocket — ending the runtime, so: every host→plugin call goes through `fit` (`MAX_SEND` 15 MiB of serialized args; over → image blocks become a `[mime, N MB: omitted, too large to send to a plugin]` text block — `Oversize::OmitImagesInMessages` (hooks: only in arg 0's `messages`, never a call's `args`, which a policy may return as a rewrite) / `OmitImages` (`on_event`: the whole event) — still over → `ExtensionError` (deny / skip / tool fails); `after_tool` and `call_tool` use `call_exact` = `Oversize::Refuse` (after dropping `after_tool`'s duplicate `text`), so an oversized output is withheld and oversized args fail the tool, never altered; the over-limit warning is logged once per process, then debug; the pi adapter keys `message_end`'s seen set by `seenAs` (each image as `mime, N MB`, the host placeholder likewise), so a message is checked once whether it arrived whole or omitted; tested end to end in `languages_test` `a_conversation_over_the_message_limit…` (fails without the guard); plugin→host can't be guarded host-side: both adapters cap a result's images at `MAX_RESULT_IMAGE_CHARS` 12 MiB base64, the rest named in text), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7+ allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.9.1, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1, and the **host services** (`plugins/dsh/host-services.json`: `dsh-fs-local`, `dsh-attachment-local`, `dsh-credentials-local`, `dsh-storage`, `dsh-session-persistence`, `dsh-skill`, `dsh-skill-filesystem` — the `-local` packages provide `fs`/`attachments`/`credentials` themselves; loading the interface package beside one fails "service registered"; they keep data and read credentials under `DSH_HOME` (else `~/.dsh`), so hosts set the runtime's `DSH_HOME` (probe: a temp dir; frontend examples: `YOAGENT_DSH_HOME`, else `$TMPDIR/yoagent-dsh-home`); `settings` needs DSH app boot (configEditor, profileContext, Cordis loader) and `subagents`/`llm`/`agents` DSH's model layer — not provided; test `dsh_host_services_start_headless_in_their_own_dsh_home`); separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; past `MAX_RESULT_IMAGE_CHARS` 12 MiB of base64 per result → `[…: left out, the result's images are too large together]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.9.1; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `info` report each (`reported` set; expected, not a fault — refusals stay `warn`; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.9.1 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.9.1), `requirements.txt` (`rutis==0.9.1`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 22.19+ / `plugins/node_modules` / Python 3.10+ with rutis 0.9 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.88, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 22 — the floor; pi needs 22.19, rutis 0.9 22 — Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. +Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.9"` (0.x caret = 0.9.x; since 0.8 the packages in rutis's release train share one version — move the host and the npm/PyPI runtimes together: a 0.9 host sends call origins a 0.8 runtime rejects; rutis 0.9 needs Rust 1.88, so `rust-version` is 1.88 (yoagent's own stays 1.86); any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Bun / Python plugins** (`languages.rs`, features `node`/`bun`/`python`/`websocket` → optional `rutis-bridge = "0.9"`; `bun`: Bun 1.4+ runs the same TS plugins (`@arcships/rutis-bun` 0.9.1 in `plugins/package.json`, `LocalRuntime::bun(package, project).interpreter(exe)` + `RuntimeResolver::modules` → rows `bun:`); `languages_test` runs `every_hook` / `signal_aborts` on Node and on Bun (`bun_runtime()`: `YOAGENT_RUTIS_BUN` or `bun` on PATH, 1.4+; the rutis-loader dev-dependency enables `bun`); CI installs it with `oven-sh/setup-bun` 1.4; `go`: Go plugins via the rutis Go SDK (`plugins/go/` — own `go.mod` (`go 1.24`, `github.com/arcships/rutis/go/rutis` v0.9.1) — registers `go-policy`: a `before_tool` denying `bash`, `slow_gate` waiting on its call `ctx.Done()`, a `go_echo` tool; the handler is a `map[string]any` of funcs (functions cross by reference; the `signal` field decodes as nil; the call's context also ends when the hook returns (Go's `defer cancel`), so "never cancelled after completing" is not observable from Go; the host's cancel cancels the call's context)); `tests/go_test.rs` (`required-features = ["go"]`) builds it with `go build`, runs it through `GoResolver` + `GoRuntimes` (rows `go:go-policy`), checks the tool + an `after_tool` redaction, that Go's denial / returned error / malformed answer each deny, that a policy timeout and a run cancel cancel Go's context, and that unloading the row removes the handler (disposer `Effect`), after which `bash` runs; a failed `go build` with Go present fails, never skips (Go 1.24+ or `SKIPPED:`); CI `actions/setup-go` 1.24, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.9 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (per image; rutis 0.9 closes a channel on any message over 16 MiB — local `MAX_MESSAGE` and websocket — ending the runtime, so: every host→plugin call goes through `fit` (`MAX_SEND` 15 MiB of serialized args; over → image blocks become a `[mime, N MB: omitted, too large to send to a plugin]` text block — `Oversize::OmitImagesInMessages` (hooks: only in arg 0's `messages`, never a call's `args`, which a policy may return as a rewrite) / `OmitImages` (`on_event`: the whole event) — still over → `ExtensionError` (deny / skip / tool fails); `after_tool` and `call_tool` use `call_exact` = `Oversize::Refuse` (after dropping `after_tool`'s duplicate `text`), so an oversized output is withheld and oversized args fail the tool, never altered; the over-limit warning is logged once per process, then debug; the pi adapter keys `message_end`'s seen set by `seenAs` (each image as `mime, N MB`, the host placeholder likewise), so a message is checked once whether it arrived whole or omitted; tested end to end in `languages_test` `a_conversation_over_the_message_limit…` (fails without the guard); plugin→host can't be guarded host-side: both adapters cap a result's images at `MAX_RESULT_IMAGE_CHARS` 12 MiB base64, the rest named in text), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7+ allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.9.1, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1, and the **host services** (`plugins/dsh/host-services.json`: `dsh-fs-local`, `dsh-attachment-local`, `dsh-credentials-local`, `dsh-storage`, `dsh-session-persistence`, `dsh-skill`, `dsh-skill-filesystem` — the `-local` packages provide `fs`/`attachments`/`credentials` themselves; loading the interface package beside one fails "service registered"; attachments, `.credentials.yaml` (must be 0600, DSH refuses otherwise) and skills live under `DSH_HOME` (else `~/.dsh`), skills also under `DSH_AGENTS_HOME` (else `~/.agents`), so hosts set both (probe: a temp dir; frontend: `DshHome` — `YOAGENT_DSH_HOME`, else a private 0700 dir per run; examples only with `--dsh-services`); NOT covered: the inherited process env (credentials-local ranks it first), `/.env`, the project's `.dsh/skills` / `.agents/skills`, and the filesystem (`dsh-fs-local`, no sandbox); `dsh-storage` / `dsh-session-persistence` are hubs with no backend loaded (they make the services exist; nothing is stored); `settings` needs DSH app boot (configEditor, profileContext, Cordis loader) and `subagents`/`llm`/`agents` DSH's model layer — not provided; test `dsh_host_services_start_headless_in_their_own_dsh_home`: all six services exist, a plugin reads a key from the given `DSH_HOME`'s `.credentials.yaml` (source `file`), nothing is created under `HOME`); separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; past `MAX_RESULT_IMAGE_CHARS` 12 MiB of base64 per result → `[…: left out, the result's images are too large together]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.9.1; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `info` report each (`reported` set; expected, not a fault — refusals stay `warn`; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.9.1 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.9.1), `requirements.txt` (`rutis==0.9.1`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 22.19+ / `plugins/node_modules` / Python 3.10+ with rutis 0.9 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.88, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 22 — the floor; pi needs 22.19, rutis 0.9 22 — Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. ### Plugin compatibility matrix diff --git a/integrations/yoagent-rutis/README.md b/integrations/yoagent-rutis/README.md index 52511e9..bf1ea85 100644 --- a/integrations/yoagent-rutis/README.md +++ b/integrations/yoagent-rutis/README.md @@ -329,11 +329,17 @@ session persistence, skills. DSH ships each as a package, and they run headless: [`plugins/dsh/host-services.json`](plugins/dsh/host-services.json) lists them as rows (`dsh-fs-local`, `dsh-attachment-local`, `dsh-credentials-local`, `dsh-storage`, `dsh-session-persistence`, -`dsh-skill`, `dsh-skill-filesystem`); load them before the plugins. They keep -their data, and read credentials (`.credentials.yaml`, `.env`), under -`DSH_HOME`, else `~/.dsh`: **give the runtime a `DSH_HOME` of the host's -own** (rutis's `Launcher::env`; yoagent-frontend's `PluginHostBuilder::env`) -unless the plugins should see the user's DSH keys and skills. A plugin that +`dsh-skill`, `dsh-skill-filesystem`); load them before the plugins, and +only for plugins that need them. Attachments, the credentials file +(`.credentials.yaml`, mode 600) and skills live under `DSH_HOME`, else +`~/.dsh`, and skills also under `DSH_AGENTS_HOME`, else `~/.agents`: **give +the runtime homes of the host's own** (rutis's `Launcher::env`; +yoagent-frontend's `PluginHostBuilder::env` and `DshHome`) unless the +plugins should see the user's DSH keys and skills. That keeps those +directories out, not everything: the runtime inherits the host's +environment, which DSH's credentials service reads first (an API key there +is readable), it reads the project's `.env` and skill folders, and +`dsh-fs-local` gives plugins the whole filesystem. A plugin that needs DSH's app — its web server and UI, its model layer (`llm`), agents, `settings` — still does not run; the [plugin compatibility](../../docs/evals/plugin-compatibility.md) report diff --git a/integrations/yoagent-rutis/examples/ecosystem_probe.rs b/integrations/yoagent-rutis/examples/ecosystem_probe.rs index 5cecd29..47bd013 100644 --- a/integrations/yoagent-rutis/examples/ecosystem_probe.rs +++ b/integrations/yoagent-rutis/examples/ecosystem_probe.rs @@ -365,11 +365,11 @@ fn dsh_base_rows(base: &Path) -> Value { // The services a headless DSH host runs (fs, attachments, credentials, // storage, session persistence, skills): DSH's own packages, listed in // `plugins/dsh/host-services.json` for every host to load. - let services: Vec = - std::fs::read_to_string(plugins_dir().join("dsh/host-services.json")) - .ok() - .and_then(|text| serde_json::from_str(&text).ok()) - .expect("plugins/dsh/host-services.json"); + let path = plugins_dir().join("dsh/host-services.json"); + let services: Vec = std::fs::read_to_string(&path) + .map_err(|e| e.to_string()) + .and_then(|text| serde_json::from_str(&text).map_err(|e| e.to_string())) + .unwrap_or_else(|e| panic!("{}: {e}", path.display())); let mut rows = services; rows.extend([ json!({ "id": "base-web", "name": "@deepseek-ai/dsh-web" }), @@ -384,13 +384,17 @@ fn dsh_base_rows(base: &Path) -> Value { async fn probe_dsh(logs: Logs, base: PathBuf, rows: Value, inject: Vec) -> Value { let mut result = json!({ "ecosystem": "dsh" }); let runtime = base.join("node_modules/@arcships/rutis-runtime"); - // A DSH home of its own: the user's real one (its credentials) is never - // handed to the plugins probed. + // A DSH home (and skills home) of its own: `~/.dsh` and `~/.agents` are + // not handed to the plugins probed (the process environment still is). let dsh_home = tempfile::tempdir().expect("a temp DSH_HOME"); + let agents_home = dsh_home.path().join("agents"); let host = match host_with( &runtime, &base.join("package.json"), - &[("DSH_HOME", dsh_home.path())], + &[ + ("DSH_HOME", dsh_home.path()), + ("DSH_AGENTS_HOME", &agents_home), + ], ) .await { diff --git a/integrations/yoagent-rutis/tests/dsh_test.rs b/integrations/yoagent-rutis/tests/dsh_test.rs index 731378f..6b911a9 100644 --- a/integrations/yoagent-rutis/tests/dsh_test.rs +++ b/integrations/yoagent-rutis/tests/dsh_test.rs @@ -122,11 +122,11 @@ impl HostDispatch for ScriptedUi { } async fn host(runtime: &Path, ui: Option>) -> Host { - host_with(runtime, ui, None).await + host_with(runtime, ui, &[]).await } -/// [`host`], the DSH runtime given `DSH_HOME` when `dsh_home` is set. -async fn host_with(runtime: &Path, ui: Option>, dsh_home: Option<&Path>) -> Host { +/// [`host`], the DSH runtime's process given `env` on top of what it inherits. +async fn host_with(runtime: &Path, ui: Option>, env: &[(&str, &Path)]) -> Host { let root = Ctx::root().unwrap(); let bridge = RutisBridge::install(&root).unwrap(); let mut catalog = ServiceCatalog::new(); @@ -136,10 +136,11 @@ async fn host_with(runtime: &Path, ui: Option>, dsh_home: Option root.provide_as::(host_key("ui"), ui) .unwrap(); } - let launcher = match dsh_home { - Some(home) => Launcher::node(runtime).env("DSH_HOME", home), - None => Launcher::node(runtime), - }; + let launcher = env + .iter() + .fold(Launcher::node(runtime), |launcher, (name, value)| { + launcher.env(*name, *value) + }); let node = LocalRuntime::launcher("node", launcher, dsh_dir().join("package.json")); let resolver = Arc::new(RuntimeResolver::node(node.handle()).with_catalog(&catalog)); root.plugin(node); @@ -640,9 +641,12 @@ async fn without_a_frontend_a_dsh_ask_is_denied() { } } -/// `plugins/dsh/host-services.json`: DSH's own services start headless, in a -/// DSH home of the host's choosing, and the services DSH plugins wait for -/// exist in the runtime (checked from inside it, as a plugin sees them). +/// `plugins/dsh/host-services.json`: DSH's own services start headless, and +/// with `DSH_HOME` / `DSH_AGENTS_HOME` set they use that home, not the +/// user's: a plugin reads a key stored there through DSH's credentials +/// service, and nothing is created under `HOME` (pointed at an empty dir). +/// The services DSH plugins wait for exist in the runtime (checked from +/// inside it, as a plugin sees them). #[tokio::test(flavor = "multi_thread")] async fn dsh_host_services_start_headless_in_their_own_dsh_home() { let Some(runtime) = node_runtime() else { @@ -650,9 +654,50 @@ async fn dsh_host_services_start_headless_in_their_own_dsh_home() { }; let dir = tempfile::tempdir().unwrap(); let home = dir.path().join("dsh-home"); - std::fs::create_dir_all(&home).unwrap(); - let out = dir.path().join("services.json"); - let host = host_with(&runtime, None, Some(&home)).await; + let agents = dir.path().join("agents"); + let user = dir.path().join("user"); + for d in [&home, &user] { + std::fs::create_dir_all(d).unwrap(); + } + std::fs::write( + home.join(".credentials.yaml"), + "version: 1\nrefs:\n YOAGENT_TEST_KEY: from-the-given-home\n", + ) + .unwrap(); + // DSH refuses a credentials file others can read. + std::fs::set_permissions( + home.join(".credentials.yaml"), + std::os::unix::fs::PermissionsExt::from_mode(0o600), + ) + .unwrap(); + let services_out = dir.path().join("services.json"); + let key_out = dir.path().join("key.txt"); + // A plain Cordis plugin reading one key through the credentials service. + let reader = dir.path().join("read-key.mjs"); + std::fs::write( + &reader, + format!( + "import {{ writeFileSync }} from 'node:fs'\n\ + export const name = 'read-key'\n\ + export const inject = ['credentials']\n\ + export async function apply(ctx) {{\n\ + const found = await ctx.credentials.resolve('YOAGENT_TEST_KEY')\n\ + writeFileSync({:?}, String(found?.value) + ' ' + String(found?.source))\n\ + }}\n", + key_out.display().to_string() + ), + ) + .unwrap(); + let host = host_with( + &runtime, + None, + &[ + ("DSH_HOME", &home), + ("DSH_AGENTS_HOME", &agents), + ("HOME", &user), + ], + ) + .await; let services = [ "fs", "attachments", @@ -669,19 +714,32 @@ async fn dsh_host_services_start_headless_in_their_own_dsh_home() { json!({ "id": "system-prompt", "name": "@deepseek-ai/dsh-system-prompt" }), json!({ "id": "tools", "name": "@deepseek-ai/dsh-tools" }), json!({ "id": "adapter", "name": dsh_dir().join("dsh-tools-adapter.ts") }), + json!({ "id": "read-key", "name": reader }), json!({ "id": "probe-services", "name": dsh_dir().join("probe-services.ts"), - "config": { "names": services, "out": out, "waitMs": 1000 }, + "config": { "names": services, "out": services_out, "waitMs": 1000 }, }), ]); host.load(Value::Array(rows)).await; - let report: Value = serde_json::from_str(&wait_file(&out, |t| !t.is_empty()).await).unwrap(); + let report: Value = + serde_json::from_str(&wait_file(&services_out, |t| !t.is_empty()).await).unwrap(); assert_eq!(report["missing"], json!([]), "{report}"); assert_eq!( report["present"].as_array().map(Vec::len), Some(services.len()), "{report}" ); + assert_eq!( + wait_file(&key_out, |t| !t.is_empty()).await, + "from-the-given-home file", + "the key comes from the given DSH_HOME" + ); + let created: Vec<_> = std::fs::read_dir(&user) + .unwrap() + .flatten() + .map(|e| e.file_name()) + .collect(); + assert!(created.is_empty(), "nothing under HOME: {created:?}"); host.root.shutdown().await.unwrap(); } From b4053f4fe62556e7f85773ee7c12296927680ffe Mon Sep 17 00:00:00 2001 From: Yuanhao Li Date: Mon, 12 Oct 2026 15:04:40 +0200 Subject: [PATCH 4/4] test(dsh): a plugin needing the host services, end to end; the report counts failures - plugins/dsh/fixture-needs-services.ts: a dsh tool plugin injecting all six host services; its tool reads a key through `credentials`. dsh_test `a_dsh_plugin_needing_host_services_runs_only_with_them`: with host-services.json loaded its tool answers from the given DSH_HOME; without, the plugin never starts and the tool is never offered. - scripts/ecosystem-matrix.mjs: the summary counts packages that failed to load or install (DSH's 4 + 12 + 3 left out modsearch's failure) and says the DSH numbers are with the host services loaded; the report is regenerated from the same results (--from-json, no new probe). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01T7iq5hpndSiHQcnAsywKuG --- docs/evals/plugin-compatibility.md | 2 +- integrations/yoagent-rutis/CLAUDE.md | 2 +- .../plugins/dsh/fixture-needs-services.ts | 39 ++++++++++ integrations/yoagent-rutis/tests/dsh_test.rs | 76 +++++++++++++++++++ scripts/ecosystem-matrix.mjs | 9 ++- 5 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 integrations/yoagent-rutis/plugins/dsh/fixture-needs-services.ts diff --git a/docs/evals/plugin-compatibility.md b/docs/evals/plugin-compatibility.md index bb01873..645329f 100644 --- a/docs/evals/plugin-compatibility.md +++ b/docs/evals/plugin-compatibility.md @@ -13,7 +13,7 @@ every tool was exercised. **In short.** - **pi:** 12 of 20 give an agent run tools, tool-call or input hooks, or prompt additions (3 fully). 5 are refused because they hook what yoagent cannot enforce, or their startup needs pi app internals; 3 register nothing usable as installed. Their skills: all 118 (in 7 packages) load with yoagent's skills loader. -- **DSH:** 4 of 20 give an agent tools or prompt guidance. 12 need DSH's own app (its sessions, web server, model layer); 3 add nothing an agent uses (UI or settings). DSH's popular plugins mostly extend the DSH app rather than add agent tools. +- **DSH:** 4 of 20 give an agent tools or prompt guidance, with DSH's own host services loaded (files, attachments, credentials, storage, session persistence, skills: `plugins/dsh/host-services.json`). 12 need DSH's own app (its web server, model layer, agents, settings, …); 3 add nothing an agent uses (UI or settings). 1 fails to load or install (the table says why). DSH's popular plugins mostly extend the DSH app rather than add agent tools. **Results.** - **works**: loads; everything it registers maps to yoagent. diff --git a/integrations/yoagent-rutis/CLAUDE.md b/integrations/yoagent-rutis/CLAUDE.md index e353061..46e613e 100644 --- a/integrations/yoagent-rutis/CLAUDE.md +++ b/integrations/yoagent-rutis/CLAUDE.md @@ -6,7 +6,7 @@ Guidance for Claude Code when working in `integrations/yoagent-rutis/`. The repo ## rutis bridge (`integrations/yoagent-rutis/`, separate crate) -Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.9"` (0.x caret = 0.9.x; since 0.8 the packages in rutis's release train share one version — move the host and the npm/PyPI runtimes together: a 0.9 host sends call origins a 0.8 runtime rejects; rutis 0.9 needs Rust 1.88, so `rust-version` is 1.88 (yoagent's own stays 1.86); any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Bun / Python plugins** (`languages.rs`, features `node`/`bun`/`python`/`websocket` → optional `rutis-bridge = "0.9"`; `bun`: Bun 1.4+ runs the same TS plugins (`@arcships/rutis-bun` 0.9.1 in `plugins/package.json`, `LocalRuntime::bun(package, project).interpreter(exe)` + `RuntimeResolver::modules` → rows `bun:`); `languages_test` runs `every_hook` / `signal_aborts` on Node and on Bun (`bun_runtime()`: `YOAGENT_RUTIS_BUN` or `bun` on PATH, 1.4+; the rutis-loader dev-dependency enables `bun`); CI installs it with `oven-sh/setup-bun` 1.4; `go`: Go plugins via the rutis Go SDK (`plugins/go/` — own `go.mod` (`go 1.24`, `github.com/arcships/rutis/go/rutis` v0.9.1) — registers `go-policy`: a `before_tool` denying `bash`, `slow_gate` waiting on its call `ctx.Done()`, a `go_echo` tool; the handler is a `map[string]any` of funcs (functions cross by reference; the `signal` field decodes as nil; the call's context also ends when the hook returns (Go's `defer cancel`), so "never cancelled after completing" is not observable from Go; the host's cancel cancels the call's context)); `tests/go_test.rs` (`required-features = ["go"]`) builds it with `go build`, runs it through `GoResolver` + `GoRuntimes` (rows `go:go-policy`), checks the tool + an `after_tool` redaction, that Go's denial / returned error / malformed answer each deny, that a policy timeout and a run cancel cancel Go's context, and that unloading the row removes the handler (disposer `Effect`), after which `bash` runs; a failed `go build` with Go present fails, never skips (Go 1.24+ or `SKIPPED:`); CI `actions/setup-go` 1.24, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.9 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (per image; rutis 0.9 closes a channel on any message over 16 MiB — local `MAX_MESSAGE` and websocket — ending the runtime, so: every host→plugin call goes through `fit` (`MAX_SEND` 15 MiB of serialized args; over → image blocks become a `[mime, N MB: omitted, too large to send to a plugin]` text block — `Oversize::OmitImagesInMessages` (hooks: only in arg 0's `messages`, never a call's `args`, which a policy may return as a rewrite) / `OmitImages` (`on_event`: the whole event) — still over → `ExtensionError` (deny / skip / tool fails); `after_tool` and `call_tool` use `call_exact` = `Oversize::Refuse` (after dropping `after_tool`'s duplicate `text`), so an oversized output is withheld and oversized args fail the tool, never altered; the over-limit warning is logged once per process, then debug; the pi adapter keys `message_end`'s seen set by `seenAs` (each image as `mime, N MB`, the host placeholder likewise), so a message is checked once whether it arrived whole or omitted; tested end to end in `languages_test` `a_conversation_over_the_message_limit…` (fails without the guard); plugin→host can't be guarded host-side: both adapters cap a result's images at `MAX_RESULT_IMAGE_CHARS` 12 MiB base64, the rest named in text), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7+ allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.9.1, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1, and the **host services** (`plugins/dsh/host-services.json`: `dsh-fs-local`, `dsh-attachment-local`, `dsh-credentials-local`, `dsh-storage`, `dsh-session-persistence`, `dsh-skill`, `dsh-skill-filesystem` — the `-local` packages provide `fs`/`attachments`/`credentials` themselves; loading the interface package beside one fails "service registered"; attachments, `.credentials.yaml` (must be 0600, DSH refuses otherwise) and skills live under `DSH_HOME` (else `~/.dsh`), skills also under `DSH_AGENTS_HOME` (else `~/.agents`), so hosts set both (probe: a temp dir; frontend: `DshHome` — `YOAGENT_DSH_HOME`, else a private 0700 dir per run; examples only with `--dsh-services`); NOT covered: the inherited process env (credentials-local ranks it first), `/.env`, the project's `.dsh/skills` / `.agents/skills`, and the filesystem (`dsh-fs-local`, no sandbox); `dsh-storage` / `dsh-session-persistence` are hubs with no backend loaded (they make the services exist; nothing is stored); `settings` needs DSH app boot (configEditor, profileContext, Cordis loader) and `subagents`/`llm`/`agents` DSH's model layer — not provided; test `dsh_host_services_start_headless_in_their_own_dsh_home`: all six services exist, a plugin reads a key from the given `DSH_HOME`'s `.credentials.yaml` (source `file`), nothing is created under `HOME`); separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; past `MAX_RESULT_IMAGE_CHARS` 12 MiB of base64 per result → `[…: left out, the result's images are too large together]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.9.1; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `info` report each (`reported` set; expected, not a fault — refusals stay `warn`; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.9.1 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.9.1), `requirements.txt` (`rutis==0.9.1`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 22.19+ / `plugins/node_modules` / Python 3.10+ with rutis 0.9 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.88, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 22 — the floor; pi needs 22.19, rutis 0.9 22 — Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. +Its own `Cargo.toml` (not a workspace member; cargo leaves the nested package out of `cargo package` for yoagent automatically), `yoagent` by path + version, `rutis = "0.9"` (0.x caret = 0.9.x; since 0.8 the packages in rutis's release train share one version — move the host and the npm/PyPI runtimes together: a 0.9 host sends call origins a 0.8 runtime rejects; rutis 0.9 needs Rust 1.88, so `rust-version` is 1.88 (yoagent's own stays 1.86); any rutis minor bump is a yoagent-rutis minor bump — rutis types are in its API; `pub use rutis`). yoagent's core must never depend on rutis; the bridge uses only yoagent's public API. Built on `Extension` (#250): `RutisBridge::install(&root)` — **on the root**: the bridge is bound to the installing ctx's generation, so on a plugin's ctx it reads as stopped for good once that plugin reloads — provides the `Registry` service (rutis holds one binding per key, so plugins add entries; an insertion-ordered `Vec` keyed by `seq`). A plugin registers a `Handler` (closure builder, `Clone`: a name + `with_messages()` (opt-in: `messages: Option` = `Arc>` (LLM messages only, custom dropped) on the `Turn` (the request's messages), `ToolCall` for `before_tool` (history incl. the calling assistant message — yoagent pushes it before tools) and `Stop` (answer last); built once per hook call when any handler of the run asked (`Hooks::messages`), stripped for the others by `for_handler`; JS/Python `options.messages: true`, needs `before_model`/`before_tool`/`on_stop`; serde `rc` feature) + `with_tool`/`with_tools` (per run)/`with_before_tool`/`with_after_tool`/`with_before_model`/`with_on_input`/`with_on_stop`/`with_finish` each sync or `_async` (owned arg → future of `Result<_, ExtensionError>`), `with_on_event` sync; args are plain-data `Serialize` structs `ToolCall { tool, call_id, args, user_request, latest_user_text, run }`, `Turn`, `Input`, `Stop`, each with `#[serde(flatten)] run: RunInfo { run_id, label, depth, delegated_by, parent_run_id }`; decisions are yoagent's `ToolDecision`/`TurnDecision`/`InputDecision`/`StopDecision`, `after_tool` edits yoagent's `ToolOutput`). Behind it the crate-private `HandlerImpl` trait (`hooks() -> Hooks`, `static_tools`, one async method per hook, `events(run) -> Option>`), which language handlers implement too. `Registry::register(ctx, handler)` / `PluginCtxExt::register_handler` ties the entry to `ctx.effect_named` on the plugin's fiber (removed on dispose/restart/update/dependency eviction); handler names and **static** tool names unique across plugins (`CordisError::ServiceExists`, warned with the holder, no retry); each entry has a `Gate` = the plugin generation's `cancellation_token()` (cancelled at the start of an unload) + a `removed` token. `bridge.extension()` → `RutisExtension` (`impl Extension`, name "rutis"; host-set `.required()`, `.filters_tool_output()`, `.rechecks_modified_calls()`, `.require_policy()`, `.with_{policy,input,turn}_timeout(Option)`, `.with_timeout`): `start_run` snapshots available entries (`Registry::snapshot`) into a `RunState` (`impl RunHooks`). Every handler call goes through `extension::call` (gate checked → `Missed::Unavailable`; `catch_unwind`; `select!` against the gate going (in flight → unavailable); per-hook timeout → `Missed::Failed`); yoagent's own dispatcher already races cancel. Combination, registration order: `tools` static then per-run, dedup earlier wins, each wrapped in `LiveTool` (call after unload → "no longer available", in flight → "plugin unloaded during the call"); `on_input` first `Reject`, any miss rejects; `before_model` notes joined `\n`, first `Stop` ends, a miss is skipped (required → `Fail`); `before_tool` `Deny` wins / `Modify` feeds next / any miss (incl. unavailable) denies; `after_tool` chained, a failure → `Err` (yoagent withholds; required fails the run), an unavailable handler → the bridge withholds itself and returns `Ok` (an unload never fails a run); `on_stop` `Continue` messages joined, miss skipped/required fails; `on_event` sync per handler (panic → that handler off for the run; never unwinds, so publishing and other handlers go on); `finish` flushes event sinks, then calls all handlers concurrently. A required `tools`/`on_event` failure is never raised as a panic (yoagent would switch the whole extension's `on_event` off — bus publishing and every other handler): it is recorded in `RunState::failure` (tool calls denied / output withheld while pending, handed to yoagent through `RunHooks::take_failure` (required only), which the loop polls at every boundary incl. the run's end; only one on `AgentEnd` is just logged). Static tools of a handler already unavailable at `tools` are not offered. Timeouts: policy 60 s (`before_tool`, `after_tool`, `on_stop`), input 30 s (`on_input`), turn 5 s (`before_model`, `tools`, `finish`). `require_policy()` = a run starting with no `before_tool` handler denies every call (covers the reload window; no input counterpart). `Host::is_closed` (the generation token captured at **install** — a disposed or restarted root reads as stopped for good, since the registry went with that generation — + the ctx's current token + root fiber state) at `start_run` → deny every tool call / reject input / no notes, and per `before_tool` call (a shutdown mid-run). Events: `on_event` publishes every event with rutis `emit` as `AgentEventEmitted { run_id, label, depth, event }` (no listener → no task; one queue per bus; skipped when closed). Removed in #250: the four adapters, `attach`/`AgentRutisExt`, `ToolRegistry`, the `event_sender` tee, the waterfall/serial chains and their workarounds. `PluginCtxExt` (sealed: `register_handler`, `provide_tool` (= a `tool:` handler), `on_agent_event`) + `AgentPlugin::new(handler)` (named after the handler, injects `Registry`). Published with yoagent 0.25 (yoagent-rutis 0.1.0, requires yoagent 0.25). Tests share `tests/common` (`setup()`, `Setup(name, closure)`, `GreeterFactory`, `plugin`/`handler`/`deny_all`/`run_error`); policy edges drive `RutisExtension::start_run` + `RunHooks` directly with `RunContext::new` / `ToolCallRequest::new`. **TypeScript / Bun / Python plugins** (`languages.rs`, features `node`/`bun`/`python`/`websocket` → optional `rutis-bridge = "0.9"`; `bun`: Bun 1.4+ runs the same TS plugins (`@arcships/rutis-bun` 0.9.1 in `plugins/package.json`, `LocalRuntime::bun(package, project).interpreter(exe)` + `RuntimeResolver::modules` → rows `bun:`); `languages_test` runs `every_hook` / `signal_aborts` on Node and on Bun (`bun_runtime()`: `YOAGENT_RUTIS_BUN` or `bun` on PATH, 1.4+; the rutis-loader dev-dependency enables `bun`); CI installs it with `oven-sh/setup-bun` 1.4; `go`: Go plugins via the rutis Go SDK (`plugins/go/` — own `go.mod` (`go 1.24`, `github.com/arcships/rutis/go/rutis` v0.9.1) — registers `go-policy`: a `before_tool` denying `bash`, `slow_gate` waiting on its call `ctx.Done()`, a `go_echo` tool; the handler is a `map[string]any` of funcs (functions cross by reference; the `signal` field decodes as nil; the call's context also ends when the hook returns (Go's `defer cancel`), so "never cancelled after completing" is not observable from Go; the host's cancel cancels the call's context)); `tests/go_test.rs` (`required-features = ["go"]`) builds it with `go build`, runs it through `GoResolver` + `GoRuntimes` (rows `go:go-policy`), checks the tool + an `after_tool` redaction, that Go's denial / returned error / malformed answer each deny, that a policy timeout and a run cancel cancel Go's context, and that unloading the row removes the handler (disposer `Effect`), after which `bash` runs; a failed `go build` with Go present fails, never skips (Go 1.24+ or `SKIPPED:`); CI `actions/setup-go` 1.24, `default-features = false`; the default build is Rust-only; `rutis-loader` 0.9 only a dev-dependency): `install` also provides `dyn HostDispatch` under `host_key("yoagent")` (`methods` = `{register: sync, log: async}`; `log(level, message, context?)` → `tracing` at target `yoagent_rutis::plugin` (`error`/`info`/`debug`, else `warn`) with a `run_id` field from `context.run_id` (any other context field ignored; empty when none), cut at `MAX_LOG_CHARS` 8192 characters; **never returns an error** (a rejected async call a plugin doesn't catch kills its Node runtime: no `unhandledRejection` handler) — non-string message/level/run_id written as JSON, missing ones empty; in Python an un-awaited `log` coroutine is never sent; rutis gives a missing method a throwing stand-in, so adapters wrap the call in try + `.catch`; test `tests/plugin_log_test.rs`, own binary, global subscriber). `register(name, handler, options?)`: the handler is a live object (`Reference::is_object`: JS objects with functions, Python instances; hooks probed with a sync `get` on the plugin's call chain — `undefined`/`null`/Python `AttributeError` = absent, a function = present, anything else or another error refuses the registration — called with `call_method_async`, so `this` works) or a `Value::Record` of function refs (a Python dict; a `None`/`null` entry = absent, as on an object); `tools` needs `call_tool`; `options.events` (AgentEvent `type` tags; unknown ones warned) is required with `on_event` and only with it; returns a `Value::callback` disposer (cancels the registration's lifetime token = its `Gate` generation, removes the entry); `session::caller()` (the runtime's `Connection`) is watched with `closed()`, so a crashed or exited runtime's handlers are removed. `RemoteHandler` implements `HandlerImpl`: every hook is one `call` with JSON args (`after_tool(call, output)` two), results parsed strictly (`null` = default; `{deny}`/`{args}`, string/`{note}`/`{stop}`, `{reject}`, `{continue}`/`{fail}`, `{text,details,is_error}` with typed fields (`{}` = empty text, like `after_tool`'s edit) — or `content` blocks instead of `text` (never both), parsed by `content_blocks` (yoagent's JSON shape: `{type:"text",text}` / `{type:"image",data,mimeType}` — data decoded with `base64` STANDARD, 1 B to `MAX_IMAGE_BYTES` 10 MB (per image; rutis 0.9 closes a channel on any message over 16 MiB — local `MAX_MESSAGE` and websocket — ending the runtime, so: every host→plugin call goes through `fit` (`MAX_SEND` 15 MiB of serialized args; over → image blocks become a `[mime, N MB: omitted, too large to send to a plugin]` text block — `Oversize::OmitImagesInMessages` (hooks: only in arg 0's `messages`, never a call's `args`, which a policy may return as a rewrite) / `OmitImages` (`on_event`: the whole event) — still over → `ExtensionError` (deny / skip / tool fails); `after_tool` and `call_tool` use `call_exact` = `Oversize::Refuse` (after dropping `after_tool`'s duplicate `text`), so an oversized output is withheld and oversized args fail the tool, never altered; the over-limit warning is logged once per process, then debug; the pi adapter keys `message_end`'s seen set by `seenAs` (each image as `mime, N MB`, the host placeholder likewise), so a message is checked once whether it arrived whole or omitted; tested end to end in `languages_test` `a_conversation_over_the_message_limit…` (fails without the guard); plugin→host can't be guarded host-side: both adapters cap a result's images at `MAX_RESULT_IMAGE_CHARS` 12 MiB base64, the rest named in text), mimeType in `IMAGE_TYPES` png/jpeg/gif/webp; nothing else; in `after_tool` an image identical to one in the given output (`kept`) passes unchecked) — which `after_tool`'s edit also takes (`content` replaces every block; `after_tool` sees `output.content`); an `is_error` result's message is its text blocks joined, `{args}` must be an object; anything else is an error, so it fails closed); `RemoteTool` runs `call_tool` (no timeout), raced against the tool's cancel; `on_event` is delivered by one task per run and handler (in order, the extension's turn timeout per event, a bounded queue of 1024, filtered by `event_type` before serializing); the first failure (error, timeout, full queue, a panicking delivery — each delivery runs inside `catch_unwind` — or a delivery task found gone: `TrySendError::Closed` on `send`, or a `flush` whose send or ack fails) is kept in the sink: the task logs it (`warn!`) and drops the rest, `send` stops queueing, and `RunState` reads it via `EventSink::failure` — right after each `send` in `on_event` and in `check_sinks` at every decision point (`pending`/`take_failure`) — never as a panic; `EventSink::flush` (an ack through the queue) before `finish`; yoagent sends `AgentEnd` after `finish`, so the queue stays open until the hooks drop. Cancel handle: `Target::call` turns the first argument (when an object) into a `Value::Record` of its fields as `Value::Data` plus `signal: Value::Signal` (`with_signal`, `SIGNAL_FIELD`) — rutis-bridge 0.7+ allows a signal nested in a call argument; the JS runtime decodes it as a real `AbortSignal`, Python as `rutis.peer.Signal` (`.cancelled`, `await .wait()`) — so it is aborted whenever the host drops the call future (run cancel, hook timeout, plugin unload; never after completion), and fixed-signature Python methods still accept the call (a positional signal would break them). `on_event` uses `call_plain` (no handle: events are data). Python's `json.dumps(call)` now refuses the argument (documented). Tested in `languages_test` (`JS_CANCEL` / `PY_CANCEL` fixtures: run cancel and policy timeout abort, completed `*_quick` calls never do). **dsh adapter** (`plugins/dsh/`, own `package.json` + lock pinning `@arcships/rutis*` 0.9.1, dsh 0.2.0-rc.2 incl. `dsh-settings` (dsh-free-search imports an error class from it), `@deepseek-ai/cordis` 4.0.4, `dsh-free-search` 0.8.1, and the **host services** (`plugins/dsh/host-services.json`: `dsh-fs-local`, `dsh-attachment-local`, `dsh-credentials-local`, `dsh-storage`, `dsh-session-persistence`, `dsh-skill`, `dsh-skill-filesystem` — the `-local` packages provide `fs`/`attachments`/`credentials` themselves; loading the interface package beside one fails "service registered"; attachments, `.credentials.yaml` (must be 0600, DSH refuses otherwise) and skills live under `DSH_HOME` (else `~/.dsh`), skills also under `DSH_AGENTS_HOME` (else `~/.agents`), so hosts set both (probe: a temp dir; frontend: `DshHome` — `YOAGENT_DSH_HOME`, else a private 0700 dir per run; examples only with `--dsh-services`); NOT covered: the inherited process env (credentials-local ranks it first), `/.env`, the project's `.dsh/skills` / `.agents/skills`, and the filesystem (`dsh-fs-local`, no sandbox); `dsh-storage` / `dsh-session-persistence` are hubs with no backend loaded (they make the services exist; nothing is stored); `settings` needs DSH app boot (configEditor, profileContext, Cordis loader) and `subagents`/`llm`/`agents` DSH's model layer — not provided; test `dsh_host_services_start_headless_in_their_own_dsh_home`: all six services exist, a plugin reads a key from the given `DSH_HOME`'s `.credentials.yaml` (source `file`), nothing is created under `HOME`); `plugins/dsh/fixture-needs-services.ts` (injects all six, registers `fixture_services` reading `YOAGENT_TEST_KEY` via `credentials`) + test `a_dsh_plugin_needing_host_services_runs_only_with_them` (with the services: the tool answers from DSH_HOME; without: the plugin never starts, the tool is not offered); the matrix summary counts failed/not-installed packages too; separate from `plugins/package.json` to keep that install small): `dsh-tools-adapter.ts` (a rutis `definePlugin`, injects `tools`, `systemPrompt`, `yoagent`; `tools` = `schemas()` (config allowlist `tools`), `call_tool` = `execute({callId: "yoagent:", name, arguments, signal: call.signal})`, `isError` → `{text, is_error: true}`, text blocks kept, an image block (`{attachment: ImageAttachmentRef}`) read with the `attachments` service (`readImage(ref, signal)` → base64 + `ref.mediaType`) looked up **per image** via `ctx.use` in a try (never injected — the adapter runs without a store; none or a failed read → `[image …: not available here]` text; dsh's `offloaded` → `[…: offloaded]`; over `MAX_IMAGE_BYTES` 3.75 MB (ref `bytes` or read size) → `[…: too large to send]`; past `MAX_RESULT_IMAGE_CHARS` 12 MiB of base64 per result → `[…: left out, the result's images are too large together]`; an `isError` result's images are not read), other blocks named, `before_model` = `systemPrompt.assemble()` minus `harness:identity` / `deployment:persona-{prefix,suffix}`, each section rendered alone with `renderPrompt` (unset variables → skipped), joined under `[Guidance from dsh plugins]`, capped at `maxNoteChars` 2000; `call_tool` also runs the definition's (`tools.get(name)`) pure `presentCall(args)` / `presentResult(args, {content, isError, meta})` and returns them with a **successful** result as `details.view = {call?, result?}` (an error result has none: the bridge's `RemoteTool` drops `details` on `is_error`); `plain()`: content blocks → text (`[type block]`), `toWellFormed` values **and keys**, BigInt → digits, non-finite → null, `toJSON` honoured, functions dropped; over `MAX_VIEW_CHARS` 100k or not JSON → left out; a throwing presenter or lookup → no card, `warn` once per presenter then debug; never fails the call); `host-dialogs.ts` (a **plain Cordis plugin**, not `definePlugin` — a leaf plugin's ctx has only `use`/`provide`/`effect`, no `on`; `inject = ['ui', 'yoagent']`, so it starts only on hosts that share `ui` (`yoagent` for `say` → the host's log); `tools/pre-execute` listener prepended (`ctx.on(.., true)` = outermost, sees every guard's combined decision), acting only on `ask` for calls with no agent and a `yoagent:` call id → `confirm` (reason/displayReason.en + args pretty JSON, whole up to `MAX_ARGS_CHARS` 4000, else head 3/4 + length note + tail, so the end of a command is never hidden) → `true` allow / else deny "was not approved (declined, or no answer in time)" (the host's confirm default is `false`) / cancel on abort; a host failure → deny "asking the user failed: …"; dsh's `ApprovalService` is not used (needs a dsh agent + open turn for its audit log); `user-questions/request` answerer for agentless requests (what `dsh-tool-ask-user`'s legacy mode sends): single select = options + `OTHER` ("Other (type an answer)") → input as `custom`, `multiSelect` → `select{multiple}`, no options → input; answers `{id, selected, custom?}` (labels de-duplicated; a blank input or an empty multiple choice = skip `selected: []`); `null` (dismissed, timed out, withdrawn, no frontend — re-checked per question) → throws `Unanswered` ("the user did not answer question …"), never a made-up answer; a host failure → throws "asking the user failed: …" (not `next()`); both step aside when `ui.frontends()` is 0 at the start (a throwing `frontends` → closed, warned once); each question keyed `dsh-`, withdrawn on the signal's abort); `fixture-tools.ts` (a plain Cordis plugin: `defineTool` echo (with terminal `presentCall`/`presentResult`) / fail / slow-until-abort writing `config.abortFile` / `fixture_guarded` (a `tools/pre-execute` listener asks for it) / `fixture_odd` (presentCall throws, presentResult returns BigInt, NaN, lone surrogates in a value and a key, a Date, a function, an image block) / `fixture_huge` (a card over the cap), and a `fixture:guidance` section); `tests/dsh_test.rs` `ScriptedUi` (scripted answers, `frontends` count, `fail` flag): 6 tests incl. dialogs, failures/non-answers, card defences, no frontend; `package.json` also pins `@deepseek-ai/dsh-user-questions` + `@deepseek-ai/dsh-tool-ask-user` 0.2.0-rc.2 (installing on macOS drops other platforms' `node-addon-require-builtin-*` entries from the lock — an npm bug — restore them from git before committing); `tests/dsh_test.rs` (`required-features = ["node"]`, skips without `plugins/dsh/node_modules` unless `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`); `examples/dsh_tools.rs` (feature `node`; rows `dsh-web`, `dsh-system-prompt`, `dsh-tools`, `dsh-free-search` (bing, en-US), the adapter; scripted `platform_search` hits the real web; `--live` DeepSeek via `DEEPSEEK_API_KEY`; self-checks). **pi adapter** (`plugins/pi/`, own `package.json` + lock pinning `@earendil-works/pi-coding-agent`/`pi-ai`/`pi-tui` 1.1.0, `typebox`, `jiti`, rutis 0.9.1; `plugins/pi/node_modules` excluded from the package): `pi-extensions-adapter.ts` (a rutis plugin, async `apply`, injects `yoagent`; config `extensions` (files or dirs with `index.ts`, relative to `cwd`), `cwd`, `name` (default `pi-extensions`), `toolNames`, `withoutBuiltins`, `allowUnmapped`, `strict`) loads pi extensions with pi's own `loadExtensions`, imported **by file** (`dist/core/extensions/loader.js` resolved from the package: the export map has only `discoverAndLoadExtensions`, which also loads `/.pi/extensions` and `~/.pi/agent/extensions`), then drives `Extension.handlers`/`.tools` itself, matching pi 1.1.0's runner/agent loop. **Fail closed by default:** loader errors, a throwing `session_start` handler, an unfired `DECIDING_EVENTS` handler (`context_with_system`, `before_provider_request`, `before_provider_headers`, and `context`/`message_end` when not `watching`; unless in `allowUnmapped`) and a pi tool named like a `TOOL_NAMES`/`toolNames` key not in `withoutBuiltins` (`checkShadowing`) refuse the load; registered after load (`checkUnmapped(false)`/`checkShadowing(false)` from `tools()`, `before_model` and `before_tool`, so a mid-run registration stops that run) they set `refusal` → every `before_tool` denies, `on_input` rejects, `before_model` stops, `after_tool` throws. `CHECKED_EVENTS` (`context`, `message_end`) present at load → `watching` → registered with `{messages: true}` (+ an `on_stop` hook): `checkEnded` runs `message_end` handlers on a `piMessage` copy of each run message not yet seen (keyed by `canon`, per run, serialized by `inOrder` — parallel calls share one assistant message) in `before_tool` (before any tool of that message), `before_model` and `on_stop`; `checkContext` runs `context` handlers on copies of `turn.messages` in `before_model`; a returned `message`/`messages` differing by `canon`, or an in-place edit → `stopWith` (sets `refusal`: deny / stop / `{fail}` in `on_stop`); a throw → warn, no change (as pi); a later-registered one while not watching → refused as before. Tested in `pi_test` `message_end_and_context_handlers_are_checked_at_run_time`. Other unfired events, commands/shortcuts/flags/renderers/providers/virtual models/MCP servers → one `info` report each (`reported` set; expected, not a fault — refusals stay `warn`; load error with `strict`). The `session_shutdown` effect is registered before the refusals. Runtime: `setActiveTools` → `active` allowlist over the names pi knows (`inactive()`: registered pi tools + `names` values; MCP/sub-agent/host tools unaffected); as pi's `_applyToolLoadout`, a listed tool is available unless `hidden` (`defaultActive` ignored), and one registered after it that would activate on registration joins it (`knownAtActivation`); `getActiveTools` (refreshes first, so a tool registered since joins — pi refreshes on `registerTool`; without an allowlist it omits `withoutBuiltins`)/`getAllTools` from it (built-ins as `builtin:` `SourceInfo`, minus `withoutBuiltins` and names an extension tool takes); `appendEntry` → `sessionManager.appendCustomEntry`, `sendMessage` → `sessionManager.appendCustomMessageEntry` (a `custom_message` entry, as pi's runtime stores it; `display: true` ones also `report`ed at info; `triggerTurn`/`deliverAs` warned, never a turn) — so pi tools that record results this way don't fail; `UNSUPPORTED_ACTIONS` (`sendUserMessage`, `setSessionName`, ...) and `setModel` replaced with "not available in yoagent" throws. Tools = `registered()` (first registration of a name wins) filtered by `available()` (exposure `direct`/`model-only`, `defaultActive !== false`, in `active`); read per run. `before_tool`: `refusal` → deny; a built-in whose pi counterpart an available pi tool has → deny ("call X instead"); a judged name outside `active` → deny; for a pi tool `prepareArguments` **on a `structuredClone`** then pi-ai's `validateToolArguments` (throw → deny) before the policies, `judged` (`run_id/call_id` → `canon` JSON) recorded, `{args}` when it differs from the raw call; for a built-in (`YOAGENT_KEYS` tool only — never an MCP/host tool's `path`) a relative `path` is resolved against `cwd` (when there are policies), then `ARGS` — keyed by the **yoagent** name — maps `edit_file` (one `edits` entry; more → deny), `search` (`include`↔`glob`, `ignoreCase = !(case_sensitive ?? false)`), `list_files` (`pattern` default `*`), and a rewritten field outside `YOAGENT_KEYS` denies. `{block}`/a throw → deny; `terminate: true` → `terminated` → the run's next `before_model` stops (stricter than pi's all-results batch rule). `tools()` returns nothing once `refusal` is set. `call_tool` runs a pi tool only if `canon(call.args)` equals `judged` (a later handler's rewrite → error result, not run). `canon` = key-sorted JSON with non-integers at 15 significant digits (args cross serde_json, which may reorder keys and re-parse a float one ULP off). `after_tool` builds the pi event from `output.content` (yoagent's text/image JSON is pi's shape), chains, returns only the fields set (`text` only when content was replaced); a throwing handler → throw (the bridge withholds) — unlike pi. `on_input`: `handled`/`transform`/throw → reject. `before_agent_start` → `before_model`, handlers once per run (memoized by `run_id`, cleared in `finish` with `terminated`/`judged`), the note on every request; `event.systemPrompt` and `ctx.getSystemPrompt()` are a NUL-delimited placeholder; per handler try/catch (a throw, a non-string or placeholder-less `systemPrompt`, a `systemPromptOptions` write) → skipped with a warning; a `message` dropped, its `systemPrompt` kept. Context: `ui` = print-mode no-op Proxy (`confirm` false, `notify` → `console.warn`, `onTerminalInput` → unsubscribe fn, `then` undefined, `theme` = identity Proxy), `hasUI` false, `isProjectTrusted` false, `sessionManager` = `SessionManager.inMemory(cwd)`, `abort`/`shutdown`/`compact` throw; `ctx.executeTool(name, args, {signal})` = `executeNested`: pi tools only (`callable()`: `direct` while available, `codemode`, `deferred`), prepare + validate, `tool_call` handlers with `parentToolCallId`, `execute` (ctx with its own nested `executeTool`, depth ≤ 8), `tool_result` handlers (a tool's throw is an error result that still goes through them, as in pi; a handler's throw → error outcome, withheld), ids `/`, missing args → `{}`, refused once `refusal` is set (checks re-run per nested call), depth < 8, returns `{toolCall, result, isError, durationMs}` (error `details` `{}`), never rejects; `ctx.tools` = `callable()`. Diagnostics go through a module-level `report` → `yoagent.log` when the host has it, else `console.warn` (also `ctx.ui.notify`). Fixtures `fixture-extension.ts` (pi_echo/pi_fail/pi_slow/pi_dynamic, `.env` block, bash/edit rewrites and a two-edit rewrite on `multi.txt`, `SECRET` redaction, a prompt addition, `session_shutdown` writes `shutdown.txt`, a command) and `fixture-extra.ts` (duplicate `pi_echo`, `defaultActive:false`, `isError`, an `edit` override whose `prepareArguments` mutates in place, a throwing policy, grep glob/ignoreCase and find path rewrites, `timeout` and `terminate` markers, a details-only and a throwing result handler, four `before_agent_start` handlers). `tests/pi_test.rs` (`required-features = ["node"]`, 9 tests (+ a mid-run deciding event), and `tests/plugin_log_test.rs` (own binary, global `tracing` subscriber: the adapter's warning arrives at target `yoagent_rutis::plugin`); the weekly workflow `.github/workflows/pi-latest.yml` runs `pi_test` against `@latest` pi packages installed `--no-save` over the pinned ones (red = check the adapter before bumping the pin); the original 8: end to end, less common paths, strict, same-name refusal + `withoutBuiltins`, load refusals + `allowUnmapped`, `setActiveTools`, per-run notes + input rejection, a later handler's rewrite not run; temp extensions via `extension()`; skips without `plugins/pi/node_modules`), `examples/pi_extensions.rs` (feature `node`; extension paths as args, default the fixture, which it self-checks (the write denied as protected, bash ran); chdirs into the temp project; `--without NAME` drops a yoagent built-in and passes it as `withoutBuiltins`; `--live` DeepSeek, `--prompt`). Tried with 11 unchanged pi examples (pi 1.1.0, Oct 2026, scripted + live DeepSeek). **rutis-agent example** `examples/rutis-agent-tools/` (own `[workspace]`, `/target` git-ignored, excluded from the package, not auto-discovered; run by the `rutis-bridge` CI job, its workspace in that job's rust-cache): rutis-agent from git (`arcships/rutis` tag v0.9.1 — crates.io's 0.2.0 is on rutis 0.2) + `[patch.crates-io] rutis` at the same tag, so one `rutis` (verified with `cargo tree -d`); a `RutisAgentTools` plugin (injects `tools_key()` + `Registry`) registers a `Handler` whose per-run `with_tools` maps `ToolRegistry::schemas()` (`aimux_core::options::Tool::Function`) to tools calling `execute(&tool_call(..), &ctx.cancel)` (`ok: false` → `ToolError::Failed`, cancelled → `Cancelled`; an output that parses wholly as `{"content": [text/image blocks]}` with at least one image → those yoagent blocks — the adapter's image convention, rutis-agent itself has text results only — shown by `dot_picture`); shows `replace_text`, a hot-added `word_count`, and (scripted) a cancelled `slow` tool; `--live` DeepSeek. `ToolSpec` `description`/`parameters` `null` = missing. `plugins/`: `package.json` + lock (`@arcships/rutis`, `@arcships/rutis-runtime` 0.9.1), `requirements.txt` (`rutis==0.9.1`), `yoagent.d.ts` (the handler shape), `ts/example.ts`, `python/yoagent_example.py`; `node_modules`/`.venv` git-ignored and excluded from the package. `tests/languages_test.rs` (`required-features = ["node", "python"]`, unix): real runtimes via rutis-loader rows, a `probe` host service, fixtures written to a temp dir (JS imports `@arcships/rutis` by `file://` URL); a missing Node 22.19+ / `plugins/node_modules` / Python 3.10+ with rutis 0.9 (`YOAGENT_RUTIS_PYTHON` or `plugins/.venv/bin/python`) prints `SKIPPED:` and passes, or fails with `YOAGENT_RUTIS_REQUIRE_RUNTIMES=1`. Example `language_plugins` (same features). CI jobs `rutis-bridge` (fmt/clippy/doc/test/example, `--manifest-path`), `rutis-bridge-msrv` (1.88, yoke-derive pin; also checks the language features) and `rutis-bridge-languages` (Linux, Node 22 — the floor; pi needs 22.19, rutis 0.9 22 — Python 3.12: `npm ci` in `plugins/`, `plugins/dsh/` and `plugins/pi/` (setup-node `cache: npm` keyed on the three lockfiles), clippy per feature, docs, tests with the runtimes required, the `language_plugins` and (scripted, fixture) `pi_extensions` examples; `dsh_tools` is not run in CI — it needs the network). A PR whose base is not main gets no CI (the workflow triggers on main/release). Run its checks with `--manifest-path integrations/yoagent-rutis/Cargo.toml`. ### Plugin compatibility matrix diff --git a/integrations/yoagent-rutis/plugins/dsh/fixture-needs-services.ts b/integrations/yoagent-rutis/plugins/dsh/fixture-needs-services.ts new file mode 100644 index 0000000..5e84c10 --- /dev/null +++ b/integrations/yoagent-rutis/plugins/dsh/fixture-needs-services.ts @@ -0,0 +1,39 @@ +// A test fixture: a dsh tool plugin that needs DSH's host services, as many +// dsh plugins do (`plugins/dsh/host-services.json`). It starts only once all +// of them exist; then it writes `config.ready` and registers one tool: +// +// fixture_services {} → "credentials: " +// read through the `credentials` service +// +// `tests/dsh_test.rs` loads it with and without the host services. Not for +// production use. + +import { writeFileSync } from 'node:fs' +import { defineTool } from '@deepseek-ai/dsh-tools' + +export const name = 'yoagent-dsh-fixture-services' +export const inject = ['tools', 'fs', 'attachments', 'credentials', 'storage', 'sessionPersistence', 'skills'] + +export interface Config { + /** Written once the plugin starts (all its services exist). */ + ready?: string +} + +// The Cordis context is used untyped: Cordis types are not needed at runtime. +export function apply(ctx: any, config: Config | undefined) { + const tool = defineTool({ + name: 'fixture_services', + description: 'Report a credential read through the host services.', + parameters: {}, + output: { + schema: { type: 'string' } as const, + render: (_args: unknown, value: string) => [{ type: 'text' as const, text: value }], + }, + async execute() { + const found = await ctx.credentials.resolve('YOAGENT_TEST_KEY') + return `credentials: ${found?.value ?? 'none'}` + }, + }) + ctx.effect(() => ctx.tools.register(tool), 'fixture: fixture_services') + if (config?.ready) writeFileSync(config.ready, 'started') +} diff --git a/integrations/yoagent-rutis/tests/dsh_test.rs b/integrations/yoagent-rutis/tests/dsh_test.rs index 6b911a9..4f094b5 100644 --- a/integrations/yoagent-rutis/tests/dsh_test.rs +++ b/integrations/yoagent-rutis/tests/dsh_test.rs @@ -743,3 +743,79 @@ async fn dsh_host_services_start_headless_in_their_own_dsh_home() { assert!(created.is_empty(), "nothing under HOME: {created:?}"); host.root.shutdown().await.unwrap(); } + +/// A dsh plugin that needs DSH's host services (most popular ones do) runs +/// and reaches the agent only with `host-services.json` loaded; without it +/// the plugin never starts and its tool is never offered. +#[tokio::test(flavor = "multi_thread")] +async fn a_dsh_plugin_needing_host_services_runs_only_with_them() { + let Some(runtime) = node_runtime() else { + return; + }; + for with_services in [true, false] { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path().join("dsh-home"); + std::fs::create_dir_all(&home).unwrap(); + let credentials = home.join(".credentials.yaml"); + std::fs::write( + &credentials, + "version: 1\nrefs:\n YOAGENT_TEST_KEY: from-dsh-home\n", + ) + .unwrap(); + std::fs::set_permissions( + &credentials, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + ) + .unwrap(); + let agents = dir.path().join("agents"); + let ready = dir.path().join("ready.txt"); + let host = host_with( + &runtime, + None, + &[("DSH_HOME", &home), ("DSH_AGENTS_HOME", &agents)], + ) + .await; + let mut rows: Vec = if with_services { + serde_json::from_str( + &std::fs::read_to_string(dsh_dir().join("host-services.json")).unwrap(), + ) + .unwrap() + } else { + Vec::new() + }; + rows.extend([ + json!({ "id": "system-prompt", "name": "@deepseek-ai/dsh-system-prompt" }), + json!({ "id": "tools", "name": "@deepseek-ai/dsh-tools" }), + json!({ + "id": "needs-services", + "name": dsh_dir().join("fixture-needs-services.ts"), + "config": { "ready": ready }, + }), + json!({ "id": "adapter", "name": dsh_dir().join("dsh-tools-adapter.ts") }), + ]); + host.load(Value::Array(rows)).await; + if with_services { + wait_file(&ready, |t| t == "started").await; + } else { + // Long enough for a plugin with its services to have started. + tokio::time::sleep(Duration::from_secs(2)).await; + assert!( + !ready.exists(), + "without the host services it must not start" + ); + } + let (agent, _) = agent(vec![call("fixture_services", json!({})), text("done")]); + let mut agent = agent.with_extension(host.bridge.extension()); + let (_, results) = run(&mut agent, "go").await; + let (_, text, is_error) = &results[0]; + if with_services { + assert!( + !is_error && text == "credentials: from-dsh-home", + "{results:?}" + ); + } else { + assert!(*is_error && text.contains("not found"), "{results:?}"); + } + host.root.shutdown().await.unwrap(); + } +} diff --git a/scripts/ecosystem-matrix.mjs b/scripts/ecosystem-matrix.mjs index 85ce97b..5047bae 100644 --- a/scripts/ecosystem-matrix.mjs +++ b/scripts/ecosystem-matrix.mjs @@ -404,11 +404,16 @@ const skillLine = () => { if (found === 0) return '' return ` Their skills: ${loaded === found ? 'all' : `${loaded} of`} ${found} (in ${packages} packages) load with yoagent's skills loader.` } +// Packages that failed to load or install: never left out of the count. +const broken = (rows) => { + const k = n(rows, 'failed', 'not installed') + return k > 0 ? ` ${k} ${k === 1 ? 'fails' : 'fail'} to load or install (the table says why).` : '' +} if (raw.pi) { - summary.push(`- **pi:** ${n(raw.pi, 'works', 'works, partly')} of ${raw.pi.length} give an agent run tools, tool-call or input hooks, or prompt additions (${n(raw.pi, 'works')} fully). ${n(raw.pi, 'refused')} are refused because they hook what yoagent cannot enforce, or their startup needs pi app internals; ${n(raw.pi, 'nothing to use')} register nothing usable as installed.${skillLine()}`) + summary.push(`- **pi:** ${n(raw.pi, 'works', 'works, partly')} of ${raw.pi.length} give an agent run tools, tool-call or input hooks, or prompt additions (${n(raw.pi, 'works')} fully). ${n(raw.pi, 'refused')} are refused because they hook what yoagent cannot enforce, or their startup needs pi app internals; ${n(raw.pi, 'nothing to use')} register nothing usable as installed.${broken(raw.pi)}${skillLine()}`) } if (raw.dsh) { - summary.push(`- **DSH:** ${n(raw.dsh, 'works', 'works, partly')} of ${raw.dsh.length} give an agent tools or prompt guidance. ${n(raw.dsh, 'needs a DSH host')} need DSH's own app (its sessions, web server, model layer); ${n(raw.dsh, 'not applicable')} add nothing an agent uses (UI or settings). DSH's popular plugins mostly extend the DSH app rather than add agent tools.`) + summary.push(`- **DSH:** ${n(raw.dsh, 'works', 'works, partly')} of ${raw.dsh.length} give an agent tools or prompt guidance, with DSH's own host services loaded (files, attachments, credentials, storage, session persistence, skills: \`plugins/dsh/host-services.json\`). ${n(raw.dsh, 'needs a DSH host')} need DSH's own app (its web server, model layer, agents, settings, …); ${n(raw.dsh, 'not applicable')} add nothing an agent uses (UI or settings).${broken(raw.dsh)} DSH's popular plugins mostly extend the DSH app rather than add agent tools.`) } writeFileSync(opts.out, `# Plugin compatibility