كيف تكتب مكوّن dsh مخصصاً لـ DeepSeek Harness
أنشئ مكوّن dsh من مجلد فارغ: تعرّف إلى حقول package.json المهمة، وملف patch للتركيب، وأداة فعلية، واثنتين من الخطافات في الإصدار 0.1.0-rc.7.
ما هو مكوّن dsh فعلياً
مكوّن dsh هو حزمة npm تُصدّر دالة apply، وتحتوي على ملف YAML صغير واحد يخبر DeepSeek Harness بتحميلها. لا توجد SDK منفصلة للمكوّنات تحتاج إلى تعلّمها أولاً. إن dsh تطبيق Cordis، وعبارة «كل شيء مكوّن» حرفية: فسجل الأدوات، وحلقة الوكيل، ومخزن الجلسات، وخادم الويب كلها صفوف في شجرة المكوّنات نفسها التي تنضم إليها حزمتك.
Cordis هو إطار عام لتركيب المكوّنات. طُوّر بشكل مستقل، واستُخدم لسنوات كأساس لإطار روبوتات الدردشة Koishi. وهو يتولى التحميل وإلغاء التحميل، ويحل التبعيات بين المكوّنات. ولا يعرف شيئاً عن الوكلاء. تأتي كل الوظائف المرتبطة بالوكلاء من حزم harness المبنية فوقه، ولذلك يبدو شكل المكوّن أدناه صغيراً. معظم ما تحصل عليه موروث من الإطار.
يتكون المكوّن من جزأين. يعمل جزء المضيف في Node، ويسجل الأدوات ومستمعي الأحداث، ويمكنه توفير خدمات خاصة به. أما جزء المتصفح فيعمل داخل واجهة الويب، ويسجل مواضع الواجهة. يكون المكوّن الأول في الغالب مخصصاً للمضيف فقط، لذلك اعتبر جزء المتصفح اختيارياً إلى أن تحتاج إليه.
كُتب هذا الدليل اعتماداً على إصدار @deepseek-ai/dsh 0.1.0-rc.7، وهو وسم npm latest بتاريخ 19 August 2026. ما يزال dsh في مرحلة المعاينة للمطورين، ويذكر README الخاص به أن تغييرات تكسر التوافق ستحدث. قُرئ كل اسم مفتاح أدناه من الوثائق المصدرية والمستودع في ذلك التاريخ. راجعها مجدداً قبل الاعتماد على أي منها، لأن واجهة المعاينة قد تعيد تسمية الحقول بين الإصدارات المرشحة. إذا لم يكن harness قيد التشغيل بعد، فأعد إعداده باستخدام DeepSeek Harness على VPS ومفتاح dsh API وإعدادات النموذج أولاً، ثم عد إلى هنا.
حمِّل ملفاً تجريبياً واحداً قبل أن تنشئ أي حزمة
البدء بإنشاء الحزمة طريقة بطيئة لفهم المشكلة. حمِّل ملفاً واحداً، وتأكد من أن بيئة التشغيل تستدعي شيفرتك، ثم أنشئ الحزمة.
أنشئ مجلداً خارج نسخة checkout الخاصة بـharness، وضع فيه ملفاً واحداً.
import type { Context } from '@deepseek-ai/cordis'
export const name = 'hello-plugin'
export function apply(ctx: Context) {
console.log('[hello-plugin] plugin loaded')
}يُستخدم export const name كبيانات وصفية لوضع تسمية للمكوّن الإضافي في التشخيص. أما apply فهو العقد الكامل: يستدعي Cordis المكوّن مرة واحدة ويمرر إليه سياقاً مخصصاً له. وكل ما تسجله في ذلك السياق يُلغى تلقائياً عند التخلص من المكوّن الإضافي.
وبجواره، اكتب cordis.yml.
- insert:
- id: hello
name: '/absolute/path/to/scratch-plugin/hello.ts'شغّل الآن profile مع وضع ذلك الملف فوقه كطبقة.
dsh web --patch ./scratch-plugin/cordis.ymlإذا لم يكن dsh موجوداً في PATH، فإن npx @deepseek-ai/dsh web --patch ./scratch-plugin/cordis.yml ينفذ المهمة نفسها. قد يحمّل مسار npx ذلك إصداراً مرشحاً أقدم ومخزناً مؤقتاً، بدلاً من الإصدار الذي يصفه هذا الدليل. لذلك، إذا رفض harness أحد الأعلام الموثقة مباشرةً، فنفّذ خطوات إصلاح أخطاء تثبيت dsh والإصدار قبل أن تشك في ملفك نفسه. يجب أن ترى [hello-plugin] plugin loaded في الطرفية التي شغّلت dsh. إذا لم يظهر شيء، فهذا يعني أن الصف لم يُحلّ.
يقبل الحقل name اسم حزمة npm أو مساراً في نظام الملفات، وتذكر الوثائق الأساسية أن المسار يجب أن يكون مطلقاً. يكون ./hello.ts النسبي أول ما ينبغي فحصه عندما لا ينتج المكوّن الإضافي التجريبي أي مخرجات. والعنصر الثاني هو امتداد الملف. تُشغّل الدورة الموثقة باستخدام pnpm dsh web --patch ... من نسخة clone لمستودع harness، حيث تُحمّل إدخالات TypeScript عبر tsx. إذا حصلت على dsh من npm، فأشر في الصف إلى JavaScript عادي، أو أنشئ الملف أولاً.
--patch هو علم تشغيل، وتُطبَّق طبقته أخيراً، بعد كل حزمة وبعد تصحيح profile الخاص بك. لذلك تتغلب طبقة التجربة دائماً على ما سبقها، وهذا هو المطلوب تماماً أثناء التكرار.
اكتب أصغر أداة تنفّذ وظيفة مفيدة
يثبت سطر السجل أن الإضافة تُحمَّل. أما الأداة فتثبت أن الإضافة أصبحت جزءاً من الوكيل.
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'greet-tool'
export const inject = ['tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'greet',
description: 'Greet someone by name.',
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}export const inject = ['tools'] هو السطر الذي يغفله الناس. تبدأ الإدخالات في إعداد Cordis بالتزامن، لذلك لا يضمن موضع الصف في الملف أي شيء بشأن ترتيب التحميل. يأتي الترتيب من التبعيات المعلنة. يخبر inject نظام Cordis بالانتظار حتى يصبح ctx.tools موجوداً قبل استدعاء apply، ومن دونه قد تعمل شيفرتك في وقت لا يكون فيه السجل موجوداً لتسجيلها فيه.
يمثل باقي الكائن العقد الذي يراه النموذج. يحدد parameters مخطط الوسيطات، ويتلقى execute الوسيطات بعد تحليلها وفقاً له. يصف output.schema القيمة التي يعيدها execute، بينما يحوّل render تلك القيمة إلى كتل المحتوى التي يقرأها النموذج. يتيح الفصل بين هذين العنصرين للواجهة عرض شيء، بينما يقرأ النموذج شيئاً آخر.
ابدأ profile واطلب من المساعد تحية شخص باسمه. يعود الرد عبر execute. التسجيل من خلال ctx قابل للعكس، لذلك يؤدي التخلص من الإضافة إلى إلغاء تسجيل الأداة تلقائياً. أما الموارد التي لا يستطيع Cordis معرفتها، مثل socket أو file handle، فاستخدم ctx.effect() وسلّمه دالة تنظيف.
نقطتا التوسعة اللتان يلمسهما أول plugin فعلياً
القائمة الكاملة لنقاط الربط طويلة. لكن نقطتين منها تغطيان تقريباً كل plugin أول.
أحداث المحادثة هي التدفق الدائم والمسجّل. أسماؤها هي session/event وturn/start وturn/end وstep/start وstep/end وuser/message وassistant/message وassistant/chunk وtool/call وtool/result. اربط بها listener عادياً.
ctx.on('tool/call', (payload) => {
console.log('[my-plugin] tool/call', JSON.stringify(payload))
})اطبع payload مرة واحدة واقرأه. لا تنسخ أسماء حقول payload من أي دليل، بما في ذلك هذا الدليل، لأن بنية payload هي الجزء الأكثر تغيراً في preview API.
نقطة التوسعة الثانية هي waterfall. الأحداث agent/pre-step وagent/request وagent/request-error وllm/stream وأحداث tools/* هي waterfalls، ولهذا يملك listener الخاص بـwaterfall توقيعاً مختلفاً. فهو يتلقى callback من النوع next، ولا تستمر السلسلة إلا إذا استدعاه.
ctx.on('agent/request', async (payload, next) => {
const startedAt = Date.now()
const downstream = await next()
console.log('[my-plugin] model request took', Date.now() - startedAt, 'ms')
return downstream
})إذا نسيت await next()، فأنت لم تضف hook. بل استبدلت استدعاء النموذج بلا شيء، ويتوقف agent عند هذه النقطة، لأن الإيقاف المبكر هو السلوك المصمم لـgateway plugin الذي يرفض طلباً عمداً. هذا الاختلاف وحده يسبب معظم الالتباس عند كتابة أول plugin. اكتب استدعاء next() قبل أن تكتب أي شيء حوله.
يلتف agent/request حول استدعاء النموذج نفسه. ويحمل payload الخاص به agent الذي يجري الاستدعاء، ورقم turn المفتوح، والخطوة التي ينتمي إليها الطلب، وإشارة الإلغاء الخاصة بذلك turn. لذلك فهو نقطة الربط المناسبة لـrequest logger أو rate limiter. وتملك waterfalls من نوع tools/* البنية نفسها في مستوى أدنى. يتيح tools/pre-execute الطلب أو يرفضه أو يطلب الموافقة قبل الإرسال. ويلتف tools/execute حول الإرسال. ويمكن لـtools/post-execute استبدال النتيجة المطبّعة أو حظرها. أما tools/result فلا يراقب إلا النتيجة النهائية المجمّدة.
حزّمه كحزمة يمكن للآخرين تثبيتها
الحزمة هي حزمة npm يعلن فيها package.json عن حقل dsh.bundle يشير إلى ملف التصحيح الخاص بها. هذا التصريح هو الفرق الكامل بين ملف مؤقت وشيء قابل للتثبيت.
{
"name": "dsh-plugin-hello",
"version": "0.1.0",
"type": "module",
"main": "lib/index.js",
"files": ["lib", "cordis.patch.yml", "README.md", "LICENSE"],
"engines": { "node": "^22.19 || >=24", "dsh": ">=0.1.0-rc.6" },
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } },
"keywords": ["dsh-plugin", "deepseek-harness"],
"scripts": { "build": "tsdown", "prepare": "pnpm run build" },
"exports": {
".": { "types": "./lib/index.d.ts", "default": "./lib/index.js" },
"./cordis.patch.yml": "./cordis.patch.yml",
"./package.json": "./package.json"
}
}أما cordis.patch.yml الموجود بجانبه فهو قصير.
- insert:
- id: dsh-plugin-hello
name: dsh-plugin-helloالصف name هو اسم الحزمة، لذلك يجب أن تتطابق هاتان السلسلتان. والصف id هو ما تستهدفه طبقة لاحقة عندما يتجاوز المستخدم إعداداتك، لذلك اختر قيمة مستقرة ولا تستخدمها مرة أخرى لإضافة مختلفة.
يجب أن يسرد files قيمة cordis.patch.yml. إذا حذفتها، فسيحتوي أرشيف tarball المنشور على dsh.bundle.patch يشير إلى ملف لم يُضمَّن، ولذلك تُثبَّت الحزمة من دون أن تضيف شيئاً إلى شجرة الإضافات.
ثبّتها في ملف تعريف من الدليل الذي يحتوي على مجلد الإضافة الخاص بك.
dsh plugin --profile demo add ./dsh-plugin-hello
dsh --profile demo --dump-config
dsh --profile demoيمرّر dsh plugin --profile <name> بقية وسائطه إلى pnpm داخل دليل ملف التعريف هذا، لذلك يعمل add وremove بالطريقة نفسها التي يعمل بها pnpm. ألغِ التثبيت باستخدام dsh plugin --profile demo remove dsh-plugin-hello. ينشئ ملفا التعريف web وheadless نفسهما من القوالب المضمّنة عند الاستخدام الأول، أما أي اسم آخر لملف تعريف فيجب إنشاؤه عبر dsh plugin.
سبب غياب صفك من الشجرة المركّبة
تبدأ عملية التركيب بقائمة إدخالات فارغة، ثم تكدّس الطبقات بترتيب ثابت. أولاً، كل حزمة مذكورة في dsh.profile.bundles الخاص بالملف الشخصي، وفق الترتيب الوارد فيه. ثم cordis.patch.yml الخاص بالملف الشخصي. ثم $DSH_HOME/cordis.patch.yml. ثم أي طبقة --patch إضافية من سطر الأوامر. تستبدل الطبقات اللاحقة الصفوف السابقة وفق المعرّف.
توجد الملفات الشخصية ضمن $DSH_HOME/profiles/<name>. يحتوي مجلد الملف الشخصي على package.json يتضمن بيان dsh.profile مع قائمة bundles المرتبة، بالإضافة إلى ملف التصحيح الخاص بالمستخدم. تُحل أسماء الحزم أولاً من تثبيت dsh، ثم من node_modules الخاص بالملف الشخصي، وهو المكان الذي يضع فيه pnpm إضافة خارج شجرة التثبيت.
يطبع dsh --profile demo --dump-config الشجرة المركّبة بالكامل من دون تشغيل أي شيء، ويشكّل هذا الناتج الحد الفاصل لتصحيح الأخطاء. إذا كان معرّف صفك غائباً، فالمشكلة في التركيب: اسم لم يُحل، أو ملف تصحيح لم يُضمَّن في الحزمة. إذا كان الصف موجوداً ولم يحدث شيء، فالمشكلة في شفرتك. أجب عن هذا السؤال أولاً، وستتجنب معظم التخمين.
موضع ظهور أخطاء التحميل فعلياً
يظهر الخطأ المُثار داخل apply بوضوح. تتوقف العملية بسبب ذلك الاستثناء، وتحصل على تتبع للمكدس يشير إلى السطر الخاص بك.
أما حالات فشل الحل فتحدث بصمت. يسجّل المحمّل الوحدة التي يتعذر عليه حلها عبر مسجّل Cordis بدلاً من التسبب في تعطل العملية، ويحذّر الدليل الأصلي من أن هذه الرسائل قد لا تظهر أثناء بدء التشغيل، لأنها تُصدر قبل إرفاق مُصدّرات وحدة التحكم. لذلك يبدو خطأ في المسار مطابقاً تماماً لحالة إضافة حُمّلت ولم تنفّذ شيئاً. ولهذا يجدر بك تشغيل فحص --dump-config أعلاه قبل قراءة أي تعليمات برمجية.
أبقِ console.log باعتباره أول تعليمة في apply أثناء التطوير. ويخبرك غيابه بأي شق من المشكلة تتعامل معه، كما يمكنك حذفه لاحقاً دون تكلفة. على الخادم، شغّل أداة الاختبار في الواجهة الأمامية أثناء التكرار بدلاً من تشغيلها عبر مدير خدمات، حتى يصل خرج المحمّل إلى الطرفية لديك بدلاً من سجل يومية يتعين عليك فتحه وقراءته.
التكرار من دون إعادة تشغيل كل شيء
الإجابة الصريحة بشأن جزء المضيف اليوم هي إعادة التشغيل. تأتي حزمة تطبيق الويب مع تعطيل آلية إعادة تحميل الوحدات البرمجية الساخنة المشتركة، ويتضمن الملف ملاحظة تفيد بإعادة تفعيلها بعد اختبار دورة حياة إعادة التحميل. تكون سلسلة إعادة تحميل جهة العميل محمّلة دائماً، لكنها تبقى خاملة إلى أن يعيد مراقب إعادة البناء كتابة حزم العميل، ولذلك لا تفعل شيئاً لجزء Node لديك أيضاً.
اجعل إعادة التشغيل سريعة بدلاً من ملاحقة آلية إعادة تحميل غير متاحة بعد. احتفظ بالمكوّن الإضافي في ملف واحد. حمّله باستخدام --patch بدلاً من تثبيته في ملف تعريف، حتى لا تفصل خطوة بناء أو خطوة pnpm بين التعديل والتشغيل. سجّل كل شيء عبر ctx حتى لا تترك إعادة التشغيل أداة مكررة أو مستمعاً قديماً. غلّف كل مورد تخصّصه بنفسك داخل ctx.effect() مع دالة تنظيف فعلية، لأن العَرَض المعتاد لغياب دالة التنظيف هو فشل التشغيل الثاني بسبب منفذ ما يزال التشغيل الأول يحتفظ به.
إذا كنت تطوّر باستخدام بيئة اختبار تعمل على خادم بدلاً من حاسوبك المحمول، فلا يتغير أي مما سبق، لكن ربط Web UI مهم. يشرح الربط على loopback بالمنفذ 3080 سبب عدم فتح الصفحة تلقائياً وما يجب فعله حيال ذلك.
الجزء الخاص بالمتصفح، ومقدار الثقة الذي ينبغي منحه له
أضف هذا فقط عندما تحتاج الإضافة إلى واجهة خاصة بها. ويُعلَن عنه في حقل dsh نفسه المستخدَم للحزمة.
{
"dsh": {
"client": {
"platform": "web",
"inject": [],
"external": [],
"immediately": false
}
},
"exports": {
".": "./src/index.ts",
"./client": "./src/client/apply.ts",
"./package.json": "./package.json"
}
}يُعد "platform": "web" مطلوباً، ويُصدر الفاحص خطأً إذا لم تتضمن الحزمة تصدير ./client، ولذلك تُعد خريطة التصدير جزءاً من البيان وليست ميزة اختيارية. وتتلقى نقطة دخول العميل Cordis Context موسَّعةً بنوع وقت تشغيل العميل، وتحدث كل عمليات التسجيل داخل apply عبر ctx.slots.register. ولا يُسمح بالتأثيرات الجانبية على مستوى الوحدة هناك.
import type { Context } from 'cordis'
import type { DshClientContext } from '@deepseek-ai/dsh-client-runtime'
export async function apply(ctx: Context & DshClientContext) {
ctx.slots.register({ name: 'domain.entry.slot' }, MyComponent)
}هناك تفصيلان تجدر معرفتهما قبل البدء. إن inject في بيان العميل مخصّص للتوثيق وليس للجدولة؛ فهو يسجّل حواف التبعيات على مستوى الحزمة ولا يتحكم في ترتيب التفعيل. أما external فهو المكان الذي تعلن فيه طلبات الوحدات خارج الخط الأساسي، لكي تُنشأ قبل أن تطلبها إضافتك. هذا الجزء هو الأسرع تغيراً في الإصدار التجريبي، لذلك اقرأ packages/client/AGENTS.md في مستودع harness في اليوم الذي تكتب فيه الشيفرة، لا في اليوم الذي تقرأ فيه دليلاً عنه.
انشر، واذكر ما يلمسه المكوّن الإضافي
تؤدي إضافة موضوع dsh-plugin إلى مستودع GitHub إلى إدراجه في القائمة التي يتصفحها المستخدمون عند البحث عن المكوّنات الإضافية. وهذا إعلان عن استحقاق ثقة أشخاص لا تعرفهم، وتترتب عليه مسؤوليات. تعكس هذه المسؤوليات ما يطلب دليل فحص مكوّن dsh الإضافي قبل تثبيته من القراء التحقق منه، لذلك فإن الكتابة وفق تلك القائمة هي أسهل طريقة للالتزام بها.
- ثبّت إصدارات الاعتمادات. يؤدي استخدام نطاق caret في اعتماد انتقالي إلى تشغيل الحزمة هذا الأسبوع تعليمات برمجية مختلفة عن تلك التي كانت آمنة الأسبوع الماضي. وهذه هي الآلية نفسها التي تقف وراء هجمات سلسلة توريد npm على خادم.
- اجعل ملف manifest يوضح ما تلمسه. فقائمة
injectلديك ملخص صريح وقابل للقراءة آلياً لخدمات harness التي تستخدمها. يقرأها المراجع خلال ثوانٍ، ويكوّن رأياً عن المكوّن الإضافي استناداً إليها. - لا تُجرِ اتصالات شبكة صامتة. إذا استدعت أداة واجهة API، فاذكر المضيف في README واجعل نقطة النهاية قابلة للتهيئة. سيزيل المدققون هذه الأمور من القائمة أي مكوّن إضافي يتصل بخادم لم يذكره.
- اجعل
filesمحدوداً. فنشر مجلد عمل كامل هو الطريقة التي يصل بها ملف اعتماد متروك إلى السجل. - وفّر للمثبّتين عبر git برنامج
prepareالنصي الذي ينشئ الحزمة دون افتراضات خاصة ببيئة التطوير فقط، وأخبرهم في README بضرورة إضافة عملية الإنشاء هذه إلى قائمة السماح فيpnpm-workspace.yamlضمن ملفهم الشخصي. - ضع تاريخاً في README يطابق مرشح الإصدار الذي أنشأته واختبرته. يحتاج قراء واجهة API المعاينة إلى معرفة الإصدار الذي استخدمته.
لمعرفة الشكل الذي يبدو عليه المكوّن الإضافي المكتمل من الخارج، اقرأ مكوّنات dsh الإضافية الجديرة بالتثبيت ولاحظ ما يوضحه كل README قبل تثبيته. إذا كتبت امتدادات لوكيل آخر، فراجع كيفية تجميع مكوّنات Claude Code الإضافية للمقارنة. يزوّدك harness برسم كائنات حي وبآلية تسجيل قابلة للعكس. وهذا يمنحك صلاحيات أكبر من manifest للملفات، ويرتب عليك مسؤولية أكبر.
FAQ
هل أحتاج إلى النشر إلى npm لكتابة إضافة dsh؟
لا. يكفي استخدام مسار في طبقة overlay من cordis.yml، وتحميله باستخدام dsh web --patch ./scratch-plugin/cordis.yml، لتشغيل التعليمات البرمجية الخاصة بك داخل harness. يجب أن يكون المسار مطلقاً. لا تصبح الحزم مهمة إلا عندما يثبّت شخص آخر الإضافة. وحتى عندئذ، يمكنك تثبيت مجلد محلي باستخدام dsh plugin --profile demo add ./my-plugin لاختبار الشكل المحزّم دون استخدام registry.
لماذا تُحمَّل الإضافة لديّ، لكن الأداة لا تظهر مطلقاً؟
شغّل dsh --profile demo --dump-config أولاً. إذا كان row id الخاص بك مفقوداً من ذلك الناتج، فهذا يعني أن الإضافة لم تُركّب أصلاً، وأن السبب متعلق بالتركيب وليس بالتعليمات البرمجية. إذا كان الصف موجوداً، فتحقق من export const inject = ['tools']. تبدأ الإدخالات في إعداد Cordis بالتوازي، لذلك لا يحدد ترتيب الملفات ترتيب التحميل. من دون هذا التصريح، لا ينتظر Cordis سجل الأدوات، وقد يعمل apply الخاص بك في وقت لا يكون فيه ctx.tools متاحاً لتسجيل الأداة فيه.
ما الفرق بين cordis.yml وcordis.patch.yml؟
cordis.yml عبارة عن قائمة إدخالات كاملة. أما cordis.patch.yml فهي طبقة تُطبَّق فوق إحدى القوائم، وتستهدف الصفوف حسب id لإدراج صفوف جديدة أو استبدال إعداد موجود. يشير bundle إلى ملف patch الخاص به من خلال dsh.bundle.patch في package.json. تُطبَّق الطبقات بترتيب ثابت: كل bundle وفق الترتيب المدرج في profile، ثم ملف patch الخاص بالـprofile، ثم $DSH_HOME/cordis.patch.yml، ثم أي overlay من --patch. تكون الأولوية للطبقات اللاحقة.
هل يمكنني إعادة تحميل إضافة dsh أثناء تشغيل agent؟
ليس للجزء المضيف في web profile، اعتباراً من 0.1.0-rc.7. يوزّع ذلك bundle صف إعادة التحميل السريع للوحدات المشتركة وهو معطّل، مع ملاحظة في الملف تفيد بأنه سيعود بعد اختبار دورة حياة إعادة التحميل. صمّم عملية التطوير بحيث تعتمد على إعادة تشغيل سريعة بدلاً من ذلك: استخدم ملفاً واحداً، وحمّله عبر --patch من دون خطوة build، ونفّذ كل عمليات التسجيل من خلال ctx حتى لا يتسرّب شيء من تشغيل إلى آخر. استخدم ctx.effect() مع disposer للموارد التي لا يستطيع Cordis تنظيفها تلقائياً.