多くの開発者が DeepSeek Harness のプラグイン例を初めて読むとき、最も疑問に思いやすい点が二つあります。なぜプラグインは export const inject = ['tools'] と一行書くだけで、ctx.tools が必ず存在すると確信できるのか? そしてなぜプラグインのアンロード時に、ドキュメントには removeListener や clearInterval といった手動クリーンアップコードが一切登場しないのか? この二つの問いは、一見「依存宣言」と「リソース回収」という別々の話題に属しているように見えますが、実際には同じ一つのことを答えています。Harness はプラグインを「いたるところで副作用を生むモジュール」から「スコープツリーにぶら下がり、依存関係が明示的に宣言されたノード」へと再モデリングしたのです。本記事は 依存性注入(inject)、Effects(ctx.effect)、サービスモデル(Service) という三つの主軸に沿って展開します。まず三大組み込みサービスが何を公開しているのか、inject 配列が apply の前にどのようなタイミング保証を行っているのか、Service 基底クラスを使って自分自身をサービスの提供者にする方法を説明し、さらに ctx.effect() と Fiber スコープの記帳メカニズムに沿って、「なぜクリーンアップコードを書かなくてよいのか」という疑問を一層ずつ解き明かし、そのまま貼り付けて実行できる TypeScript の例とエンジニアリング上の落とし穴チェックリストを提示します。本記事はチュートリアル全体の前半部分であり、まずメカニズムを徹底的に解説し、登録とアンロードのタイミング、サービス検索と型ヒントの由来、自動追跡リストの境界を、具体的なフィールドと振る舞いに落とし込みます。
ctx.tools / ctx.llm / ctx.agents:3つの組み込みサービスがそれぞれ何を公開しているか
依存性注入を理解するには、まず注入される対象である「サービス」を理解する必要があります。Harness の文脈において、サービスとは、あるプラグインが他のプラグインに対して公開する名前付きの能力です。それは import された関数ライブラリではなく、ctx オブジェクトにマウントされた安定したプロパティであり、例えば ctx.tools、ctx.llm、ctx.agents です。どのプラグインでも、ctx を取得しさえすれば、この key をたどって能力を検索でき、その能力が背後で誰によって実装されているか、どのファイルで実装されているか、別のプラグインによって mock 版に置き換えられるかどうかをまったく知る必要がありません。これこそが依存性注入と「直接 import」の核心的な違いです。呼び出し側が依存するのは契約であり、実装ではありません。
Harness は起動時に3つの基本サービスを ctx にマウントし、それらが大多数のプラグインの最小限のエコシステム基盤を構成します。次の表では、これら3つの組み込みサービスのサービス名、それが何であるか、そして典型的な使い方を対照し、プラグインを書く際に自分がどのサービスを inject すべきかを素早く判断できるようにします:
| 組み込みサービス | それが何であるか | 典型的な使い方 |
|---|---|---|
| ctx.tools | ツールランタイム(ToolRuntime) | ツールの登録、ツールの呼び出し、モデルが呼び出せる能力をランタイムにマウントする |
| ctx.llm | 大規模言語モデルサービス(LLM) | モデルアダプタの登録、モデルリクエストの発行、モデル呼び出しの統一エントリポイント |
| ctx.agents | エージェントサービス(Agent) | サブエージェントの管理、マルチエージェント間のオーケストレーションとライフサイクルを担当 |
この表から2つのエンジニアリング上の意味を読み取れます。第一に、ctx.tools は「能力の登録と呼び出し」という層に向いており、関心があるのはツールの実行であり、ツールの具体的なビジネスロジックではありません。あなたのプラグインがツールオブジェクトをそれに渡せば、モデルが呼び出す必要があるときにリクエストをルーティングするのはそれの役割です。第二に、ctx.llm はモデルアクセスの統一的な窓口であり、アダプタを登録するということは、上位のビジネスコードを変更せずに基盤のプロバイダを差し替えられるということであり、リクエストを発行するということは、自分で HTTP クライアントを new する必要がないということです。第三に、ctx.agents はサブエージェントの管理をプラグインから切り離し、プラグインの責務は「サブエージェントを宣言し、それをオーケストレーションする」ことに縮退し、ライフサイクルはサービス自体に委ねられます。
ここで見落とされがちですが非常に重要な設計上の詳細があります:プラグインは key を通じてサービスを検索し、具体的な実装を import しないということです。つまり、プラグイン内で書くのは ctx.tools.register(...) であり、import { ToolRuntime } from '...' して自分でインスタンスを構築するのではありません。これは一見「import を1つ書かなくて済む」だけの違いに見えますが、実際には3つのことを決定づけます:
- 置換可能性:同じ名前の key のサービスを提供する別のプラグインがあれば、あるいは契約に適合するテストダブルを提供しても、消費側のコードは一行も変更する必要がありません。テスト時には ctx.tools を偽の実装に置き換え、プラグインが契約通りに呼び出しているかを観察できます。
- 組み合わせ可能性:複数のプラグインがそれぞれ同じサービスに自分の能力を登録でき、ツール、モデルアダプタ、サブエージェントを同じ名前空間に集約できます。プラグイン同士が互いに import して網状の依存を形成する必要はありません。
- アンロード可能性:サービスの登録は ctx を通じてフレームワークの視界に入るため、アンロード時にフレームワークは何を返却すべきかを知ることができます。逆に言えば、ctx を迂回して自分でグローバルなレジストリを維持した場合、アンロード時にフレームワークからはそれが見えず、このリソースはリークします。
エンジニアリング上もう一つよくある落とし穴があります:「サービス」と「ツール」を混同することです。サービスは名前付きの能力であり、ctx にマウントされます。ツールはあなたが ctx.tools というサービスに登録する具体的な項目です。両者は容器と内容の関係です。あるプラグインは ctx.tools というサービスを消費することも、ctx.llm というサービスを消費することも、さらに自分で新しいサービスを提供することもでき、これら3つは互いに衝突しません。inject を書くときには「自分はどの ctx.
inject 配列:apply 実行前にフレームワークが依存関係の準備完了をどう保証するか
サービスを理解したところで、次は依存関係を宣言する構文を見ていきます。あるプラグインが「私は tools サービスに依存している」と表現するには、モジュールのトップレベルで inject という名前の配列をエクスポートするだけで済みます:
// ファイルパス:scratch-plugin/src/my-tool-plugin.ts
import type { Context } from '@deepseek-ai/cordis'
export const name = 'my-tool-plugin'
// 依存関係の宣言:tools サービスが必要
export const inject = ['tools']
export function apply(ctx: Context) {
// ここに到達した時点で、ctx.tools は必ず準備完了している
ctx.tools.register(/* ... */)
}このコードは短いですが、非常に強いタイミング契約を担っています。inject に 'tools' と書かれている限り、apply が呼び出された時点で ctx.tools は必ず存在し、かつ準備完了しているのです。ここでの「準備完了」とは「プロパティは存在するが undefined かもしれない」という意味ではなく、「サービスがすでにマウントを完了しており、その公開メソッドを直接呼び出せる」という意味です。フレームワークはプラグインのロードフローの中で、まず inject 配列を読み取り、これらのサービスがすでに ctx 上で利用可能かどうかを一つずつ確認します。inject の各項目がすべて条件を満たしたときにのみ、実際にあなたの apply へと進みます。もしあるサービスがまだ準備完了していなければ、フレームワークはこのプラグインを保留し、サービスが現れてからロードをトリガーします。これが、apply 内で安心して ctx.tools.register(...) と書ける理由であり、if (ctx.tools) のような防御的な判定を書く必要がない理由です。
このタイミングをステップに展開すると、おおよそ次のような呼び出しチェーンになります:
- フレームワークがプラグインモジュールからエクスポートされた name と inject を読み取る。
- フレームワークが inject 配列内の各 key を解析し、ctx 上で対応するサービスが準備完了しているかを探す。
- すべて準備完了していれば apply(ctx) を呼び出す;準備完了していないサービスがあれば、プラグインは待機状態に入り、apply は実行されない。
- apply 内での ctx.tools への呼び出しはサービスがすでにマウントされた後に行われるため、安全にアクセスできる。
ここで特に強調すべきエンジニアリング上の要点が三つあります。第一に、inject はモジュールレベルのエクスポートであり、apply 内のローカル変数ではないということです。これは、プラグインが解析されるまさにその瞬間にフレームワークから読めなければならず、apply の実行時に初めて「誰に依存するか」を決めることはできないという意味です。この制約は意図的なものです。依存関係は静的に解析可能であり、フレームワークはロード前にトポロジカルソートを行えるため、「プラグイン A がプラグイン B に依存し、プラグイン B がさらにプラグイン A に依存する」という循環ロードや、「サービスがまだ初期化されていないのに使われてしまう」という競合状態を避けられます。第二に、inject が宣言するのはサービスの key であり、具体的なクラスやファイル名ではないということです。あなたが 'tools' と書く場合、それは ctx.tools に対応します;もしあるカスタムサービスが ctx.sessions にマウントされているなら、inject すべきは 'sessions' です。key と ctx 上のプロパティ名は一対一で対応しており、これが最も直感的で最も間違いの少ないメンタルモデルです。第三に、apply は実行タイミングであり、依存関係の宣言タイミングではないということです。多くの初心者は依存関係の判定を apply の中に書いてしまい、たとえば先にチェックしてから登録するため、冗長になるだけでなく本当の依存関係を覆い隠してしまいます;正しいやり方は依存関係をすべて inject 配列へ前倒しし、apply はビジネスロジックだけに関心を持つようにすることです。
さらに進めると、自然な疑問が生まれます:もし 'tools' を inject したが、tools サービスが置き換えられた後でもこのプラグインが正常に動作し続けてほしい場合はどうすればよいか?答えは、そもそも契約にのみ依存すべきだということです。なぜなら ctx.tools はサービスであり、その公開インターフェースは安定しており、実装を置き換えてもあなたが呼び出すメソッドのシグネチャは変わらないからです。これが、inject 配列に文字列 key だけを書けば十分である理由でもあります——契約の安定性はサービスのインターフェースによってすでに保証されており、inject 内でバージョンや出所を宣言する必要はありません。
消費側から提供側へ:Service 基底クラスでカスタム ctx.<key> をマウントする
ここまではすべて「消費側」の視点でした。私のプラグインは他者が提供する機能を必要とするため、inject を書きます。しかしプラグインエコシステムの観点から見ると、健全なシステムには必ず「提供側」として機能するプラグインが存在します。Harness はそのために Service 基底クラスを提供しており、どのプラグインでも自身の機能を名前付きサービスとして登録し、ctx の特定の key にマウントして、他のプラグインが消費できるようにします。
まずサービスの定義を明確にします。サービスとは ctx にマウントされた名前付き機能であり、どのプラグインでもサービスを提供し、他のプラグインが利用できるようにすることができます。それは安定した ctx.
Service 基底クラスを使って提供側を実装する典型的な流れは、次の三步にまとめられます。
- サービスクラスを定義し、Service 基底クラスを継承して、公開したいメソッドをクラスに実装します。
- プラグインの apply の中で、このサービスインスタンスを取り決められた ctx.
にマウントし、「サービスを提供する」という動作を完了します。 - 他のプラグインの inject 配列にその key を記述すると、フレームワークがそれらの apply 実行時にあなたのサービスがすでに準備完了していることを保証します。
ここでの鍵はマウントポイントと key の対応関係にあります。サービスを ctx.sessions にマウントすると、消費側が inject するのは 'sessions' です。サービスがアンロードされると、この key も ctx から取り除かれ、消費側プラグインの依存は満たされなくなります。この「サービスが出現 → 消費側が呼び起こされる;サービスが消失 → 消費側が保留またはアンロードされる」という連動は、依存性注入システムで最も味わう価値のある部分です。これにより、プラグイン間の協調は「どちらが先にロードされるか」という偶然の順序に依存せず、「どちらが依存を宣言したか」という明示的な契約に依存するようになります。
エンジニアリング実践において、提供側は特に二つのことに注意する必要があります。第一に、公開メソッドは小さく安定しているべきです。サービスインターフェースは一度多くの消費側に使用されると、変更コストが指数関数的に上昇するため、細粒度のメソッドをいくつか提供する方が、引数が極端に多い「万能メソッド」を一つ提供するよりも良いです。第二に、サービスはコンストラクタで重い処理を行ってはならないです。サービスがマウントされるタイミングはフレームワークのスケジューリングに影響されるため、重い初期化は起動を遅くします。より安全な方法は、重い処理を apply に入れるか、消費側が最初に呼び出したときに遅延初期化することです。
型ヒントはどこから来るのか:Service インターフェースと自動生成されるサービスページ
依存性注入システムにおいて、上級者が最も厳しい目を向けるポイントは型安全性です。もし消費側が文字列 key で ctx 上のサービスを取得するだけなら、TypeScript は ctx.tools に register メソッドがあることをどうやって知るのでしょうか。その答えは、フレームワークが Service 基底クラスと型宣言を通じて、サービスインターフェースを Context の型に注入していることにあります。言い換えれば、サービスを正しく宣言すれば、ctx.
組み込みサービスについては、さらに強調する価値があります。組み込みサービスのサービス名、公開メソッド、ソースコードの位置は、リポジトリから各サービスのサブシステムページへ自動生成されます。この一文には三層の情報が含まれています:
- サービス名:例えば tools、llm、agents。これらの key は手書きで保守される静的なリストではなく、リポジトリから生成されたものであり、コードの実際の状態と一致することが保証されます。
- 公開メソッド:各サービスが外部に公開しているメソッドも同様に、生成ブロックによって示され、ドキュメント作者が記憶を頼りに列挙するものではありません。
- ソースコードの位置:サービスがどのファイルに実装されているかは生成ページで確認でき、実装に直接ジャンプして挙動の詳細を確認するのに便利です。
ここから、非常に重要な開発規律が導かれます:プラグインを開発する際は、これらの生成ブロックとサービスの TypeScript インターフェースを基準にすべきであり、手書きの静的なサービスリストに依存してはいけません。手書きリストの問題は、それがドリフトすることです。サービスにメソッドが追加されてもリストが更新されない、サービスの引数が変更されてもリストが古いまま、といったことが起こります。一方、自動生成されるブロックと TS インターフェースは、本質的にソースコードと同期しています。不確かな箇所に遭遇したときの正しい姿勢は、二次情報のチュートリアルにある表を検索することではなく、TypeScript インターフェースのメソッドシグネチャを確認することです。
型の連鎖を完全につなげてみると、おおよそ次のようになります:Service 基底クラスがサービスインスタンス上で利用可能な公開メソッドを定義し、プラグインが提供側でサービスを ctx.
ctx.effect():ネットワーク接続のような非登録リソースのクリーンアップ入口
サービスと依存関係の話を終え、記事は第二の主線であるリソースのクリーンアップに入ります。実際の Plugin は一行のログを出力するだけではなく、リスナーの登録、ツールの登録、タイマーの起動、さらにはネットワーク接続の確立まで行います。そこで問題になります。Plugin のアンロード時、これらのリソースは誰がクリーンアップするのか? Harness の答えは——登録は ctx に任せ、クリーンアップも ctx に任せる、というものです。
一部のリソースについては、フレームワークが自動的に追跡できます。なぜならそれらは ctx の公開メソッドを通じて登録されるからです。しかし別のリソース、たとえばネットワーク接続、ファイルハンドル、サードパーティ SDK のインスタンスなどについては、フレームワークはあなたの生成ロジックを知らないため、どう破棄すべきかを自動的に推論できません。そこで必要になるのが ctx.effect() です。その使い方はとても直接的です:ctx.effect はコールバックを受け取り、コールバック内でリソースを生成してクリーンアップ関数(disposer)を返す。この disposer は Plugin のアンロード時に実行されます。
// ファイルパス:scratch-plugin/src/heartbeat.ts
import type { Context } from '@deepseek-ai/cordis'
export function apply(ctx: Context) {
ctx.effect(() => {
// タイマーを生成:5 秒ごとに heartbeat を出力
const timer = setInterval(() => {
console.log('heartbeat')
}, 5000)
// 返されるクリーンアップ関数は Plugin のアンロード時に実行される
// 等価:アンロードロジック内で手動で clearInterval する必要はない
return () => clearInterval(timer)
})
}このコードには区別すべき二つの役割があります。コールバックは「リソースの生成」を担当し、effect が呼び出された時点で即座に実行され、disposer を返します。disposer はこのコールバックの戻り値であり、「今回生成されたリソースをどう破棄するか」を記述するもので、フレームワークが Plugin のアンロード時に呼び出します。disposer のセマンティクスに注目してください。それは「適当な後始末関数」ではなく、今回の生成と厳密に対応する破棄アクションです。timer を生成したなら disposer は clearInterval を担当し、接続を開いたなら disposer は接続を切断して関連するハンドルを解放する責任を負います。この「生成と破棄が対で現れる」書き方により、リソース管理は「どこかでクリーンアップを書くことを覚えておく」から「生成時にその場でクリーンアップ方法を宣言する」へと変わり、漏れの確率を大幅に下げます。
なぜ effect が自動追跡のギャップを埋めるのか? 自動追跡の前提は「登録アクションが ctx に見えていること」であり、effect はその明示的な宣言入口だからです。あなたは自らフレームワークに「これは私が生成したリソースで、これがその破棄方法だ」と伝えます。自動追跡リストでカバーできないリソースは、すべて effect を通すべきです。エンジニアリング上、非常に実用的な判断法則があります:apply 内で何らかのオブジェクトを new したり、何らかの接続を open したり、ctx 上にないループを起動したりしたなら、それはおそらく effect によるフォローアップが必要です。逆に、ctx の登録メソッドを呼び出すだけなら、重複宣言を避けるために effect でさらに包む必要はありません。
Fiber スコープ会計:なぜアンロード時に removeListener が不要なのか
ここで冒頭の疑問に答えます。なぜクリーンアップコードを書かなくてよいのか。核心となる仕組みは Fiber スコープ にあります。ドキュメントの説明によれば、フレームワークが自動的にクリーンアップできるのは、ctx を通じたすべての登録がプラグインの Fiber スコープに記録されているからです。アンロード時、フレームワークは登録順の逆順でそれらを取り消します。この一文は情報量が非常に多いので、順に分解して見ていきます。
第一に、「ctx を通じたすべての登録」。ここで注目すべきは「ctx を通じた」という限定詞です。ctx.on で登録したイベントリスナー、ctx.tools.register で登録したツール、ctx.llm.registerAdapter で登録したアダプター、そして ctx.effect で登録したリソースは、すべてこのカテゴリに属します。これらに共通する特徴は、呼び出しが ctx 上で行われるため、フレームワークがその呼び出しの瞬間にこの登録を記録できることです。逆に、ctx を迂回してあるグローバルオブジェクトの addListener を直接呼び出した場合、フレームワークはその記録を認識できず、当然クリーンアップもできません。
第二に、「プラグインの Fiber スコープに記録される」。Fiber はプラグインインスタンスの実行コンテキストと理解できます。それ自体が記録簿を持ち、このプラグインがライフサイクル内で行ったすべての取り消し可能なアクションを記録します。各記録には「取り消し時に何を呼び出すか」と順序情報の両方が含まれます。記録をグローバルではなく Fiber に紐付けることで、非常に実用的な性質が得られます。すなわち、アンロードの粒度はプラグインレベルであるということです。あるプラグインがアンロードされると、フレームワークはこの Fiber 内の記録だけを処理すればよく、他のプラグインが登録したリソースを誤って巻き込むことはありません。たとえ二つのプラグインが同名のツールを登録していても、一方をアンロードすればそれに属する登録だけが取り消されます。
第三に、「登録順の逆順で取り消す」。これは非常に古典的なリソース管理の原則であり、スタックの後入れ先出しに似ています。後から登録されたリソースは先に登録されたリソースに依存している可能性があるため、取り消し時には逆順にする必要があります。まず後から登録されたものを外し、次に先に登録されたものを外す。そうしなければ「依存先はすでに消えたのに、消費側はまだ残っている」という宙ぶらりんな状態が生じます。具体例を挙げると、あなたのプラグインがまずツールを登録し、その後 effect を通じてこのツールを定期的に呼び出すタイマーを作成した場合、アンロード時の正しい順序は、まずタイマーを停止し、次にツール登録を取り消すことです。逆順の取り消しはこの条件を自然に満たしますが、もし正順であれば、タイマーが将来のある tick で既に取り消されたツールにアクセスし、特定が困難なエラーを引き起こす可能性があります。
これら三つを合わせれば、「手動で removeListener や clearInterval を書く必要がない」という自信がどこから来るのか理解できます。登録アクションは ctx に記録される → 記録はプラグインの Fiber スコープに入る → アンロード時に逆順で取り消す。あなたが ctx.on(...) と書けば、アンロード時のクリーンアップはフレームワークが担当します。あなたが ctx.effect を書き、disposer を返せば、アンロード時にフレームワークがあなたの disposer を呼び出します。この一連の流れの中で、あなたが能動的に行う必要がある唯一のことは、リソースの作成を ctx または effect に委ねることであり、自分で管理されない影のレジストリを開くことではありません。
自動追跡リスト:ctx.on / ctx.tools.register / ctx.llm.registerAdapter / ctx.effect のアンロード動作
最後に、「どの操作が自動的に追跡・クリーンアップされるか」を項目ごとの対照リストにまとめます。以下の表は本記事で最も暗記すべき部分の一つです。なぜなら、プラグインのアンロード時に追加コードを書く必要があるかどうかを直接左右するからです:
| 登録操作 | アンロード時の動作 |
|---|---|
| ctx.on(event, handler) | イベントリスナーが自動的に削除される |
| ctx.tools.register(tool) | ツール登録が自動的に取り消される |
| ctx.llm.registerAdapter(names, adapter) | LLM アダプター登録が自動的に取り消される |
| ctx.effect(() => cleanup) | 返された disposer クリーンアップ関数を実行する |
この表のエンジニアリング上の意味を項目ごとに読み解きます。1行目の ctx.on(event, handler):イベントリスナーは最も忘れられやすいリソースの一つです。従来の書き方では on と removeListener を必ずペアで書かなければならず、そうしないとプラグインを繰り返しロードした際に同じイベントが複数回応答されてしまいます。Harness では ctx.on と書くだけで、アンロード時にリスナーが自動的に削除され、「リスナーリークによって handler が複数回呼ばれる」という古典的なバグを根本から排除できます。2行目の ctx.tools.register(tool):ツール登録が自動的に取り消されるため、プラグインのアンロード後にモデルが既に存在しないツールへリクエストをルーティングすることがなくなり、「ツールは死んでいるのにルートは残っている」というゴースト呼び出しを回避できます。3行目の ctx.llm.registerAdapter(names, adapter):モデルアダプター登録が自動的に取り消されるため、特に実験的なアダプターを扱うプラグインに適しています——装着し、試し実行し、取り外す。アダプターがグローバルレジストリに残留して後続のリクエストを汚染する心配はありません。4行目の ctx.effect(() => cleanup):フレームワークは破棄ロジックを推論してくれませんが、コールバック内で disposer を返しているため、アンロード時にフレームワークがそれを呼び出します。これは手動でクリーンアップを書くのと等価ですが、位置が「アンロードフック」から「作成現場」へ前倒しされただけです。
この表は同時に一つの境界線を引いています:自動クリーンアップの対象となる前提は「ctx 経由で登録する」か「effect 経由で宣言する」ことです。リスト外のリソース——生の setInterval、生の setTimeout、自前で確立したネットワーク接続、サードパーティ SDK のインスタンス、グローバルキャッシュ——は、自分で能動的に ctx.effect でフォローするか、いっそ ctx が提供する等価な機能を通じて行うように変更する必要があります。これこそが本記事で最も伝えたいエンジニアリング規律です:「アンロード時に何をクリアすべきか」と問うのではなく、「作成時にそれを ctx や effect に委ねたか」と問うべきです。前者は事後补救、後者は事前治理であり、プラグインが繰り返しロード・アンロードされるシステムでは、事前治理こそが保守可能な解決策です。
このリストに沿ってさらに進むと、より深い問題があります:もし2つのプラグインが同じサービスに依存している場合、一方がアンロードされたらどうなるか?もしサービスの提供側プラグインが消費側より先にアンロードされた場合、消費側は既に無効になった ctx.
前段では inject の依存宣言と Fiber スコープの記帳メカニズムを分解して徹底的に解説し、ctx.effect の登録セマンティクスについても説明しました。この段落では、カメラを実際のプラグインにぐっと近づけます:プラグイン内にリスナー、ツール、タイマーが現れ始めたとき、アンロードの瞬間に一体何が起こるのか、誰がそのツケを払うのか。
ハートビートタイマーの実践:setInterval と clearInterval はどのように disposer に引き継がれるか
まず前段の最後で触れた最小プラグインを思い出してください。それはログを一行出力するだけで、ロードが終われば終了し、アンロード時に後始末が必要な副作用は何もありませんでした。しかしプラグインが少しでも実用的な機能を持ち始めると、状況はすぐに変わります。最も古典的なシナリオを考えてみましょう。バックグラウンドのハートビートが必要で、5 秒ごとにコンソールへ heartbeat を出力し、プラグインがまだ生きていること、イベントループがブロックされていないことを確認したいとします。直感的な書き方は、apply の中で直接 setInterval を呼び出すことです:
// 反面教材:不要这样写
export function apply(ctx) {
const timer = setInterval(() => console.log('heartbeat'), 5000)
// timer 被闭包捕获,但没有任何代码在卸载时清它
}
このコードは動作しますが、隠れたリソースリークが存在します。setInterval のハンドルがクロージャに捕捉された後、プラグインがアンロードされても、フレームワーク側にはこの timer を指す参照が一切存在しないため、当然クリーンアップする手段もありません。タイマーは 5 秒ごとに発火し続け、コールバック内の console.log もそのまま出力され続けます。さらに悪いことに、コールバック内でプラグイン内部の他のオブジェクトを参照している場合、それらのオブジェクトはクロージャの参照チェーンによって GC で回収できず、常駐メモリとなります。ホットリロードが頻繁な開発環境では、同じプラグインを繰り返しロード・アンロードすることで、数十個のゾンビタイマーが蓄積され、コンソールが heartbeat で埋め尽くされ、問題調査時に極めて大きな妨害となります。
正しい方法は、リソース作成の動作を ctx.effect に委ね、フレームワークに帳簿をつけてもらうことです。以下はそのまま貼り付けて実行できる TypeScript です:
// 文件路径:scratch-plugin/src/heartbeat.ts
import type { Context } from '@deepseek-ai/cordis'
export const name = 'heartbeat-plugin'
export function apply(ctx: Context) {
ctx.effect(() => {
// 创建定时器:每 5 秒打印一次 heartbeat
const timer = setInterval(() => {
console.log('heartbeat')
}, 5000)
// 返回的清理函数在插件卸载时执行
// 等价于:不需要你在卸载逻辑里手动 clearInterval
return () => clearInterval(timer)
})
}
このコードと反面教材との唯一の違いは、作成と破棄のペア関係を明示的に表現している点です。コールバックが作成を担当し、戻り値が破棄を担当します。フレームワークはプラグインのロード時に ctx.effect のコールバックを実行し、戻り値(関数)を取得して、それを現在のプラグインの Fiber スコープに保存します。プラグインのアンロード時、フレームワークはこのスコープを走査し、登録順の逆順で各 disposer を呼び出します。こうして clearInterval(timer) が実行され、timer ハンドルが解放され、コールバックは発火しなくなり、クロージャの参照チェーンが切断され、メモリは正常に回収できるようになります。
ここで陥りやすい落とし穴が一つあります。ctx.effect のコールバック内で非同期の作成動作を行わないでください。フレームワークはコールバックが同期的に返る時点で disposer を取得する必要があるため、コールバックの途中で await して接続オブジェクトを取得すると、戻り値が undefined になり、disposer が失われてしまいます。正しい方法は、同期的にまずハンドルを作成し、非同期の初期化は effect の外に置くか、別途 async フローと手動のクリーンアップ登録エントリを組み合わせて行うことです。
もう一つのエンジニアリング上の詳細を説明しておく価値があります。ctx.effect は複数回呼び出すことができ、ctx.on や ctx.tools.register と混在させることもできます。Fiber スコープはアンロード時に逆順で取り消されます。つまり最後に登録された effect が最初にクリーンアップされます。この順序はリソース間に依存関係がある場合に重要です。例えば、先に確立したデータベース接続、後から登録したサブスクライバーの場合、逆順でクリーンアップすることで、サブスクライバーが接続クローズ前に先に解除され、クリーンアップ中のエラーを回避できます。
disposer のセマンティクス:それは今回作成されたリソースをどのように破棄するかを記述するものである
多くの人は初めて ctx.effect に触れるとき、それを普通のイベントコールバックと混同し、「関数を渡しているのだから、これはコールバックではないか」と考える。ここで概念をはっきりと分けておく必要がある。普通のコールバックが記述するのは「事が起きたときに何をするか」であり、disposer が記述するのは「今回作成されたリソースをどのように破棄するか」である。両者はセマンティクス上、まったく異なる範疇に属する。
disposer の三つの重要な性質は、一つずつしっかり覚えておく価値がある:
- それは ctx.effect コールバックの戻り値でなければならない。任意の関数ではなく、外部で定義して渡された関数でもなく、「今回の effect 実行時に作成されたリソースに対応する破棄ロジック」である。たとえユーティリティ関数からクリーナーを import したとしても、コールバック内で return しなければ、フレームワークは認識しない。
- それはプラグインのアンロード時にのみフレームワークから呼び出される。プラグインが正常に動作している間、フレームワークが能動的にそれを実行することはない。つまり disposer は純粋なアンロードフックであり、停止、切断、フラッシュなど、オフライン時にのみ行うべき処理を安心して書くことができる。
- それは本質的に作成動作と同じクロージャ内にある。したがって、作成時に取得したローカル変数(上例の timer など)を捕捉できる。ハンドルをグローバルシングルトンや ctx 上の特定のフィールドにぶら下げる必要はなく、クロージャが自然な関連付けチャネルとなる。これが、作成と破棄を一対の形で書くことが推奨される理由でもある。
この関数を返さない場合、あるいは非関数値(undefined や文字列など)を返した場合、フレームワークは今回の effect に対してクリーンアップの入口を確立できない。経験則はこうである:ctx.effect 内で「閉じる必要のあるハンドル」を何か作成したなら、必ず disposer を対で返さなければならない。それが setInterval、WebSocket、ファイルディスクリプタ、子プロセスハンドル、あるいは自分で管理する購読リストであっても同じである。
さらに、言及しておく価値のある応用的な使い方がある。disposer 自体を async 関数にすることもできる。つまり return async () => { await conn.close() } である。フレームワークはそれを実行するが、アンロードフローのタイミングに注意する必要がある。プロジェクト内の他の箇所でアンロード完了に対して同期的なタイミング前提がある場合(例えばテストでアンロードを await した直後にリソースが解放済みであるとアサートする場合)、非同期 disposer は競合状態を引き起こす可能性があり、テスト内でアンロード完了シグナルを明示的に await する必要がある。
ログ出力から本物のプラグインへ:リスナー・ツール・タイマーを登録した後のクリーンアップは誰の責任か
視点を前段の冒頭で触れた「1行ログを出力して終わる」最小プラグインに戻そう。それは教育的な意味での出発点だが、実際のプラグインがこのような形になることはほとんどない。まともなプラグインは通常、イベントリスナーを登録し(ctx.on)、ツールレジストリに Agent から呼び出せるツールを追加し(ctx.tools.register)、LLM アダプターを登録し(ctx.llm.registerAdapter)、さらに独自の定期タスクやバックグラウンドポーリングを持つ(ctx.effect で管理する)。これらの操作はそれぞれ、フレームワーク側に取り消し可能な登録ポイントを残す。
問題の核心は責任の所在の変化にある。従来のプラグイン型アーキテクチャでは、登録とクリーンアップはプラグイン作者が手動で維持する対称的な操作のペアだった。onLoad で addEventListener したら、onUnload で removeEventListener しなければならない。 setInterval したら、 clearInterval しなければならない。クリーンアップの漏れはこの種のアーキテクチャで最もよくあるバグのカテゴリの一つであり、しかも開発段階では極めて露見しにくい。なぜなら、プラグインをアンロードしない限りすべて正常に動作し、ホットリロード、動的な無効化、テストケースの繰り返しマウント・アンマウントによって初めて表面化するからだ。
Harness のアプローチは、この対称的な責任を丸ごとフレームワークに収めることだ。ctx を通じて行われた登録はすべて現在のプラグインの Fiber スコープに記録され、アンロード時にフレームワークが一括で消し込む。素材で示されている追跡範囲は以下の通り:
ctx.on(event, handler)で登録したイベントリスナーは、アンロード時に自動的に削除され、手動で removeListener する必要はない。ctx.tools.register(tool)で登録したツールは、アンロード時に登録が自動的に取り消される。ctx.llm.registerAdapter(names, adapter)で登録した LLM アダプターは、アンロード時に登録が自動的に取り消される。ctx.effect(() => cleanup)内で作成されたリソースは、アンロード時に返された disposer クリーンアップ関数が実行される。
この4種類で、ほとんどのプラグインのリソース形態をカバーしている。つまり、プラグイン作者が一つの習慣を身につけさえすれば——登録するなら ctx が提供する口を通す。口の外でのリソース作成は ctx.effect で能動的に申告する——クリーンアップコードは基本的にプラグインから消えることになる。この習慣の価値は、数行の clearInterval を節約することではなく、「リソースライフサイクルの正確性」を人間の記憶と慎重さに依存する要素から、フレームワークの構造によって保証される不変条件へと変えることにある。クリーンアップはもはや自覚に頼らず、仕組みによって担保される。
scratch-plugin ディレクトリにある2つのファイル:my-tool-plugin.ts と heartbeat.ts の依存宣言の違い
素材には代表的なサンプルファイルが2つ示されており、これらはちょうどプラグイン開発における2種類の異なる「外部要件」の扱い方を表しているため、並べて比較する価値がある。1つ目は scratch-plugin/src/my-tool-plugin.ts で、ツールレジストリにツールを登録する必要があるため、tools サービスへの依存を宣言しなければならない。2つ目は scratch-plugin/src/heartbeat.ts で、自分専用のタイマーが1つあればよく、他のプラグインが提供する機能に依存しないため、inject は不要で、ctx.effect だけで済む。
// ファイルパス:scratch-plugin/src/my-tool-plugin.ts
import type { Context } from '@deepseek-ai/cordis'
export const name = 'my-tool-plugin'
// 依存を宣言:tools サービスが必要
export const inject = ['tools']
export function apply(ctx: Context) {
// ここに到達した時点で、ctx.tools は必ず準備完了している
ctx.tools.register(/* ... */)
}
比較すると、my-tool-plugin.ts には export const inject = ['tools'] という行が1行多く存在する。この行はプラグインとフレームワークの間の契約であり、フレームワークがこのフィールドを読み取ると、tools サービスが準備完了を確認するまでプラグインの apply の実行を遅延させる。そのため、プラグイン関数の本体では ctx.tools.register を安心して直接呼び出すことができ、null チェックやリトライ、ready チェックを書く必要はない。一方 heartbeat.ts には inject がない。使用している setInterval はランタイムのネイティブ API であり、他のプラグインが提供する機能ではないため、フレームワークが待機できる依存が存在せず、apply は即座に実行できる。唯一フレームワークに伝える必要があるのは「この timer をどう破棄するか」であり、それを ctx.effect が担う。
この違いは一文にまとめられる:inject が解決するのは「他人を待つ」こと(自分が使うサービスがいつ利用可能になるか)であり、ctx.effect が解決するのは「自分を管理する」こと(自分が作成したリソースがいつ破棄されるか)である。両者は直交しており、任意に組み合わせることができる。tools サービスに依存しつつ、定時ポーリングも開始するプラグインは、inject と ctx.effect の両方を記述することになり、両者は互いに衝突しない。
| 観点 | my-tool-plugin.ts | heartbeat.ts |
|---|---|---|
| 宣言フィールド | export const inject = ['tools'] | inject 宣言なし |
| フレームワークが apply 前に待機するもの | tools サービスの準備完了を待機 | 待機不要、即座に実行 |
| 作成するリソースの種類 | ツール登録項目(フレームワークが代行管理) | タイマーハンドル(カスタムリソース) |
| クリーンアップ手段 | ctx.tools.register の逆操作はフレームワークが自動で完了 | ctx.effect が返す disposer が clearInterval を実行 |
| クリーンアップコードを手書きする必要があるか | 不要 | disposer を書く必要はあるが、アンロードイベントの購読を書く必要はない |
| 典型的な失敗モード | inject を書き忘れて ctx.tools が undefined になる | return を書き忘れて timer がリークする |
注目すべきは、heartbeat.ts では disposer を一行手書きする必要があるものの、その位置が生成ロジックに隣接しており、「生成—破棄」が対になって現れる可読性の高い構造を形成している点である。一方、my-tool-plugin.ts のクリーンアップはそもそも現れない。登録アクション自体がフレームワークにとって可逆な記帳だからである。この二つの形態がともに、プラグインのクリーンアップ責任に関する完全な全体像を構成している。
サービス検索パス:なぜ他のプラグインは具体的な実装を直接インポートしないのか
さらに深く問い直そう。A プラグインが tools 能力を必要とするなら、なぜ直接 import { tools } from './some-impl' とせず、わざわざ回り道をして inject: ['tools'] を注入し、ctx から取得するのか。この問いの答えこそが、サービスモデルが存在するすべての理由である。
Harness はサービスを「ctx にマウントされた名前付き能力」として定義する。それは ctx.tools、ctx.llm、ctx.agents、ctx.sessions のような安定した key を占める。他のプラグインは key を通じてサービスを検索し、import を通じて具体的なクラスやオブジェクトを取得するのではない。この設計はいくつかの直接的な帰結をもたらす:
- 実装が差し替え可能。ctx.tools の背後にある具体的な実装がどれで、どのプラグインによって提供されているかを、消費側は気にしない。インターフェース契約が安定していれば、フレームワークは tools の実装を別のバージョンに差し替えられる——たとえばテスト時に mock ツールランタイムを注入し、本番時に分散トレーシング付きの実装に差し替える——消費側のコードは一行も変えずに済む。もし消費側が具体的な実装を直接 import していれば、このような差し替えにはコードかビルド設定の変更が必要となり、柔軟性は大きく損なわれる。
- ロード順序の分離。消費側は提供側がどこにあり、いつロードされるかを知る必要がない。inject 宣言は宣言的な契約であり、フレームワークが依存グラフ上でロード順序を整える。消費側は apply が呼ばれるのを待つだけである。従来の手書きのロード順序や手書きの ready ポーリングの手法は、本質的に依存グラフ管理の責任を各プラグイン作者に押し付けるものであり、規模が大きくなれば必ず誤りが生じる。
- ライフサイクルの整合。サービスにはライフサイクルがある——それは提供側プラグインに属し、提供側がアンロードされるとサービスも失効する。ctx を通じてサービスを検索することで、消費側は自然と提供側のライフサイクルに束縛される。提供側がなくなれば、消費側は連動してアンロードされるか、そもそもロードできなくなり、「消費側はまだ生きているが依存する機能はすでに消滅している」という幽霊状態は発生しない。
- 命名がすなわちインターフェース。サービスが安定した key を占めることは、プラグイン間の協働に明確な境界語彙が生まれることを意味する。プラグインドキュメントに「本プラグインは ctx.foo サービスを提供し、公開メソッドは bar/baz」と書けば、他のプラグインはそれに基づいて消費コードを書ける。この key 中心の協働方式は、パッケージを跨いで実装クラスを import するよりも粗粒度で安定しており、多様な主体が独立して開発するプラグインエコシステムのような場面により適している。
素材は特に一点を強調しており、これはエンジニアリングの規律として記憶すべきである:組み込みサービスのサービス名、公開メソッド、ソースコードの位置は、リポジトリが各サービスサブシステムページに自動生成した情報を基準とし、手書きの静的なサービス一覧に依存してはならない。手書きの一覧は陳腐化するが、生成されたページと TypeScript インターフェースこそが現在のコードと同期した権威ある情報源である。これは後述する 2026 年 9 月の実践と直接呼応する。
トラブルシューティングチェックリスト:apply で ctx.tools が取得できないときに確認すべき項目
依存関係が満たされていないときの症状は往々にして地味である。apply が実行され、最初の行で ctx.tools にアクセスした瞬間に undefined になる、あるいは出所のわからない型エラーが報告される。この種の問題は実際のエンジニアリングで発生頻度が低くなく、以下のチェックリストに沿って項目ごとに確認すれば、ほぼ根本原因を特定できる。
- inject 宣言が漏れなく書かれているか。最もよくある誤りは
export const inject = ['tools']を書き忘れることだ。フレームワークはこのフィールドだけを見てサービスを待つかどうかを決めるため、宣言がなければ待たず、tools がまだ準備できていない段階で apply が実行される可能性がある。注意すべきは、モジュールのトップレベルにエクスポート(export)する定数であり、apply の内部に書くローカル変数ではないという点だ。関数内に書いてもフレームワークからは読めない。 - サービス名(key)の綴りが一致しているか。inject 配列に書くのはサービス key の文字列であり、実際に登録されたサービス名と完全に一致していなければならない。
'tool'、'Tools'、'toolRuntime'と書いても認識されない。大文字小文字の区別は調査時に最も見落としやすい点である。TypeScript は文字列リテラル上でこれを訂正してくれないからだ。 - そのサービスを提供する Plugin が実際にロードされているか。inject は「自分がそれに依存している」と宣言するだけであり、前提としてそのサービスを確かに提供している誰かが存在する必要がある。依存チェーンの上流にある提供側 Plugin が Harness に登録されていない、あるいは自身の依存が満たされずスキップされている場合、消費側の待機は永遠に結果を得られない。Plugin 一覧に提供側が存在するか、提供側自身の inject が満たされているかを確認しよう。
- サービスがロード中ではなく就緒状態にあるか。フレームワークが約束するのは「サービスが就緒してから apply を実行する」ことだ。提供側 Plugin がサービスの非同期初期化中(例えばリモートへの接続中)である場合、apply は延期される。この状況は通常「undefined」ではなく「遅延の発生」として現れるが、提供側の初期化が例外を投げると、サービスは永遠に就緒状態に入らず、消費側は延々とハングし続ける。ロードログで提供側の状態を確認し、初期化で詰まっていないかを確かめよう。
- 誤ったスコープで ctx にアクセスしていないか。ctx.tools の可用性は Plugin の Fiber スコープに束縛されている。ctx を外部モジュールに渡し、Plugin のアンロード後に、あるいは注入関係のないコンテキストでアクセスすると、得られるのは失効したスコープかもしれない。正しいやり方は apply スコープ内で登録を完了させることであり、スコープをまたぐ ctx 参照を長期間保持しないことだ。
- 型レベルで正しい Context 型を導入しているか。例では
import type { Context } from '@deepseek-ai/cordis'を使っている。型の出所が正しくないと、実際に inject を書き間違えていてもコンパイラがエラーを出さず、あなたを救えたはずの静的チェックを失うことになる。型定義が実際のランタイムフレームワークのバージョンと一致していることを確認しよう。
このチェックリストを一句の診断口诀に凝縮すると:まず宣言があるかを見て、次に名前が正しいかを見て、次に提供側が生きているかを見て、最後に就緒しているかを見る。この順序で調べれば、ほとんどの「ctx.tools が取得できない」問題はこの数種類を出ない。
2026年9月の最新実践:生成されたサービスインターフェースと inject 宣言を基準とするプラグイン開発手法
時は2026年9月に進み、この仕組みは実践の中で明確な推奨パスを形成するに至った。その核心は「生成された権威あるインターフェースを唯一の信頼できる情報源とし、inject 宣言で消費側の依存を扱い、ctx.effect でカスタムリソースを申告する」と要約できる。
いわゆる「生成された権威あるインターフェース」とは、各サービスサブシステムのページがリポジトリによって自動生成され、サービス名、公開メソッド、およびソースコードの位置を網羅していることを指す。資料は明確に、組み込みサービスのサービス名、公開メソッド、ソースコードの位置はすべてリポジトリによって各サービスのサブシステムページへ自動生成されるため、プラグイン開発時にはこれらの生成ブロックとサービスの TypeScript インターフェースを基準とすべきであると述べている。この規律の重要性は次の点にある。プラグインエコシステムは進化し、サービスはメソッドを追加・削除するため、手書きの「自分が知っているサービス一覧」は、ほぼ必然的にいずれかのバージョン以降で誤った情報となる。一方、生成ページはコードと同源であり、インターフェース定義は型システムによって制約されている。両者が相まって、嘘をつかない依存の事実を構成する。
日々の開発アクションに落とし込むと、推奨される姿勢は次のとおりである:
- 消費コードを書く際は、まず対応するサービスの生成ページと TypeScript インターフェースを開く。サービス key の正確な綴り、公開メソッドのシグネチャ、引数と戻り値の型を確認するのであり、記憶や百度で拾った古いドキュメントに頼って書き始めるのではない。
- プラグインのトップレベルで inject 配列をエクスポートする。使用するサービス key をすべて列挙する。依存が複数あれば複数の key を書く。この一行が消費側とフレームワークの間のすべての契約であり、これを正しく書けば、残りすべてはフレームワークが担う。
- apply 内で直接 ctx.<key> を使用する。null チェックも、遅延ポーリングも、手書きの ready チェックも行わない。フレームワークがすでに準備完了を保証しているからである。もし実際に undefined を取得したなら、前節の調査チェックリストに戻るべきであり、コード内に防御的なフォールバックを加えて問題を覆い隠すべきではない。
- ctx を介した登録(on、tools.register、llm.registerAdapter)には一律でクリーンアップを書かない。フレームワークに逆順での取り消しを任せる。
- ctx の追跡範囲外の自前リソース(タイマー、接続、ハンドル)は、一律で ctx.effect で包み、同期的に disposer を返す。破棄のタイミングはフレームワークに引き渡す。「作成即申告」の習慣を身につければ、コード内に孤立した手動クリーンアップロジックは現れない。
- フレームワークをアップグレードする際は、生成ページの diff を優先的に確認する。サービスインターフェースの変化はここに反映されるため、それに基づいて inject と呼び出し箇所を調整する方が、changelog を見て推測するよりもはるかに信頼できる。
この実践の価値は、単一プラグインのコードを短くすることにとどまらず、プラグインエコシステム全体に組み合わせ可能性をもたらす点にある。任意の二つのプラグインは安定した key と宣言的な契約を通じてのみ結合し、ロード順はフレームワークが並べ替え、ライフサイクルはフレームワークが整合させ、クリーンアップはフレームワークが消し込む。プラグイン作者は thus して、注意をビジネスロジックそのものに集中でき、依存の編成やリソースの後始末の細部に分散させずに済む。これこそがタイトルのあの言葉の完全な意味である——クリーンアップコードを書かなくてよいのは、クリーンアップの責任が構造的に ctx へ移譲されているからであり、みんなが怠けてそれを省略しているからではない。
まとめとベストプラクティス
全文の要点を、そのままデスクに貼れるチェックリストに圧縮します:
- 他に依存するなら、inject で宣言する:プラグインのトップレベルで
export const inject = ['tools']とし、フレームワークが apply 実行時にサービスが準備済みであることを保証します;サービス key は実際のサービス名と一文字ずつ完全に一致させる必要があります。 - 自分自身を管理するなら、ctx.effect を使う:コールバック内でリソースを作成し、同期的に disposer を返します;setInterval には clearInterval、接続には close、購読には unsubscribe を組み合わせます。
- disposer は戻り値であり、通常のコールバックではない:これはプラグインのアンロード時にのみフレームワークから呼び出され、作成アクションとクロージャを共有するため、自然にハンドルを捕捉できます。
- フレームワークが可逆な登録は手動でクリーンアップ不要:ctx.on、ctx.tools.register、ctx.llm.registerAdapter はアンロード時に自動的に取り消され、登録の逆順で実行されます。
- サービスは安定した key で検索し、具体的な実装を import しない:実装の差し替え可能性、ロード順序の分離、ライフサイクルの整合、命名即インターフェースという四重の利益を得られます。
- ctx.tools が取得できない場合の調査順序:宣言があるか → key が正しいか → 提供側が生きているか → 準備完了か → スコープが正しいか → 型の出所が正しいか。
- 2026 年 9 月時点の権威ある情報源は生成ページと TypeScript インターフェースであり、手書きの静的サービス一覧に依存せず、アップグレード時は生成ブロックの diff を優先的に確認します。
- 一つの規律で締めくくる:閉じる必要のあるハンドルを作成する場合は、ctx の口を通すか、ctx.effect で申告するかのいずれかです;そのどちらでもない自作リソースは、リークの候補です。