@moxxy/core 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/events/factory.d.ts +6 -0
- package/dist/events/factory.d.ts.map +1 -0
- package/dist/events/factory.js +14 -0
- package/dist/events/factory.js.map +1 -0
- package/dist/events/log.d.ts +92 -0
- package/dist/events/log.d.ts.map +1 -0
- package/dist/events/log.js +244 -0
- package/dist/events/log.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +16 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +27 -0
- package/dist/logger.js.map +1 -0
- package/dist/permissions/engine.d.ts +94 -0
- package/dist/permissions/engine.d.ts.map +1 -0
- package/dist/permissions/engine.js +203 -0
- package/dist/permissions/engine.js.map +1 -0
- package/dist/permissions/resolvers.d.ts +8 -0
- package/dist/permissions/resolvers.d.ts.map +1 -0
- package/dist/permissions/resolvers.js +8 -0
- package/dist/permissions/resolvers.js.map +1 -0
- package/dist/plugins/discovery.d.ts +9 -0
- package/dist/plugins/discovery.d.ts.map +1 -0
- package/dist/plugins/discovery.js +112 -0
- package/dist/plugins/discovery.js.map +1 -0
- package/dist/plugins/host-options.d.ts +72 -0
- package/dist/plugins/host-options.d.ts.map +1 -0
- package/dist/plugins/host-options.js +2 -0
- package/dist/plugins/host-options.js.map +1 -0
- package/dist/plugins/host.d.ts +53 -0
- package/dist/plugins/host.d.ts.map +1 -0
- package/dist/plugins/host.js +269 -0
- package/dist/plugins/host.js.map +1 -0
- package/dist/plugins/lifecycle.d.ts +31 -0
- package/dist/plugins/lifecycle.d.ts.map +1 -0
- package/dist/plugins/lifecycle.js +100 -0
- package/dist/plugins/lifecycle.js.map +1 -0
- package/dist/plugins/loader.d.ts +7 -0
- package/dist/plugins/loader.d.ts.map +1 -0
- package/dist/plugins/loader.js +68 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/plugins/package-requirements.d.ts +16 -0
- package/dist/plugins/package-requirements.d.ts.map +1 -0
- package/dist/plugins/package-requirements.js +52 -0
- package/dist/plugins/package-requirements.js.map +1 -0
- package/dist/plugins/registry-kinds.d.ts +58 -0
- package/dist/plugins/registry-kinds.d.ts.map +1 -0
- package/dist/plugins/registry-kinds.js +139 -0
- package/dist/plugins/registry-kinds.js.map +1 -0
- package/dist/plugins/toposort.d.ts +24 -0
- package/dist/plugins/toposort.d.ts.map +1 -0
- package/dist/plugins/toposort.js +67 -0
- package/dist/plugins/toposort.js.map +1 -0
- package/dist/preferences.d.ts +40 -0
- package/dist/preferences.d.ts.map +1 -0
- package/dist/preferences.js +58 -0
- package/dist/preferences.js.map +1 -0
- package/dist/registries/active-backend-registry.d.ts +65 -0
- package/dist/registries/active-backend-registry.d.ts.map +1 -0
- package/dist/registries/active-backend-registry.js +108 -0
- package/dist/registries/active-backend-registry.js.map +1 -0
- package/dist/registries/active-def-registry.d.ts +59 -0
- package/dist/registries/active-def-registry.d.ts.map +1 -0
- package/dist/registries/active-def-registry.js +63 -0
- package/dist/registries/active-def-registry.js.map +1 -0
- package/dist/registries/agents.d.ts +16 -0
- package/dist/registries/agents.d.ts.map +1 -0
- package/dist/registries/agents.js +17 -0
- package/dist/registries/agents.js.map +1 -0
- package/dist/registries/cache-strategies.d.ts +12 -0
- package/dist/registries/cache-strategies.d.ts.map +1 -0
- package/dist/registries/cache-strategies.js +13 -0
- package/dist/registries/cache-strategies.js.map +1 -0
- package/dist/registries/channels.d.ts +16 -0
- package/dist/registries/channels.d.ts.map +1 -0
- package/dist/registries/channels.js +35 -0
- package/dist/registries/channels.js.map +1 -0
- package/dist/registries/commands.d.ts +29 -0
- package/dist/registries/commands.d.ts.map +1 -0
- package/dist/registries/commands.js +72 -0
- package/dist/registries/commands.js.map +1 -0
- package/dist/registries/compactors.d.ts +12 -0
- package/dist/registries/compactors.d.ts.map +1 -0
- package/dist/registries/compactors.js +13 -0
- package/dist/registries/compactors.js.map +1 -0
- package/dist/registries/def-map-registry.d.ts +33 -0
- package/dist/registries/def-map-registry.d.ts.map +1 -0
- package/dist/registries/def-map-registry.js +36 -0
- package/dist/registries/def-map-registry.js.map +1 -0
- package/dist/registries/embedders.d.ts +16 -0
- package/dist/registries/embedders.d.ts.map +1 -0
- package/dist/registries/embedders.js +17 -0
- package/dist/registries/embedders.js.map +1 -0
- package/dist/registries/isolators.d.ts +21 -0
- package/dist/registries/isolators.d.ts.map +1 -0
- package/dist/registries/isolators.js +32 -0
- package/dist/registries/isolators.js.map +1 -0
- package/dist/registries/modes.d.ts +28 -0
- package/dist/registries/modes.d.ts.map +1 -0
- package/dist/registries/modes.js +81 -0
- package/dist/registries/modes.js.map +1 -0
- package/dist/registries/providers.d.ts +44 -0
- package/dist/registries/providers.d.ts.map +1 -0
- package/dist/registries/providers.js +95 -0
- package/dist/registries/providers.js.map +1 -0
- package/dist/registries/skills.d.ts +24 -0
- package/dist/registries/skills.d.ts.map +1 -0
- package/dist/registries/skills.js +81 -0
- package/dist/registries/skills.js.map +1 -0
- package/dist/registries/surfaces.d.ts +13 -0
- package/dist/registries/surfaces.d.ts.map +1 -0
- package/dist/registries/surfaces.js +14 -0
- package/dist/registries/surfaces.js.map +1 -0
- package/dist/registries/synthesizers.d.ts +26 -0
- package/dist/registries/synthesizers.d.ts.map +1 -0
- package/dist/registries/synthesizers.js +34 -0
- package/dist/registries/synthesizers.js.map +1 -0
- package/dist/registries/tools.d.ts +49 -0
- package/dist/registries/tools.d.ts.map +1 -0
- package/dist/registries/tools.js +98 -0
- package/dist/registries/tools.js.map +1 -0
- package/dist/registries/transcribers.d.ts +17 -0
- package/dist/registries/transcribers.d.ts.map +1 -0
- package/dist/registries/transcribers.js +18 -0
- package/dist/registries/transcribers.js.map +1 -0
- package/dist/registries/tunnel-providers.d.ts +12 -0
- package/dist/registries/tunnel-providers.d.ts.map +1 -0
- package/dist/registries/tunnel-providers.js +13 -0
- package/dist/registries/tunnel-providers.js.map +1 -0
- package/dist/registries/view-renderers.d.ts +13 -0
- package/dist/registries/view-renderers.d.ts.map +1 -0
- package/dist/registries/view-renderers.js +14 -0
- package/dist/registries/view-renderers.js.map +1 -0
- package/dist/registries/workflow-executors.d.ts +13 -0
- package/dist/registries/workflow-executors.d.ts.map +1 -0
- package/dist/registries/workflow-executors.js +14 -0
- package/dist/registries/workflow-executors.js.map +1 -0
- package/dist/requirements.d.ts +59 -0
- package/dist/requirements.d.ts.map +1 -0
- package/dist/requirements.js +149 -0
- package/dist/requirements.js.map +1 -0
- package/dist/run-turn.d.ts +6 -0
- package/dist/run-turn.d.ts.map +1 -0
- package/dist/run-turn.js +141 -0
- package/dist/run-turn.js.map +1 -0
- package/dist/session-runtime.d.ts +67 -0
- package/dist/session-runtime.d.ts.map +1 -0
- package/dist/session-runtime.js +2 -0
- package/dist/session-runtime.js.map +1 -0
- package/dist/session.d.ts +203 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +399 -0
- package/dist/session.js.map +1 -0
- package/dist/sessions/persistence.d.ts +184 -0
- package/dist/sessions/persistence.d.ts.map +1 -0
- package/dist/sessions/persistence.js +442 -0
- package/dist/sessions/persistence.js.map +1 -0
- package/dist/setup-agent.d.ts +91 -0
- package/dist/setup-agent.d.ts.map +1 -0
- package/dist/setup-agent.js +138 -0
- package/dist/setup-agent.js.map +1 -0
- package/dist/skills/index.d.ts +4 -0
- package/dist/skills/index.d.ts.map +1 -0
- package/dist/skills/index.js +4 -0
- package/dist/skills/index.js.map +1 -0
- package/dist/skills/loader.d.ts +16 -0
- package/dist/skills/loader.d.ts.map +1 -0
- package/dist/skills/loader.js +99 -0
- package/dist/skills/loader.js.map +1 -0
- package/dist/skills/parse.d.ts +5 -0
- package/dist/skills/parse.d.ts.map +1 -0
- package/dist/skills/parse.js +8 -0
- package/dist/skills/parse.js.map +1 -0
- package/dist/skills/synthesize-draft.d.ts +8 -0
- package/dist/skills/synthesize-draft.d.ts.map +1 -0
- package/dist/skills/synthesize-draft.js +32 -0
- package/dist/skills/synthesize-draft.js.map +1 -0
- package/dist/skills/synthesize.d.ts +33 -0
- package/dist/skills/synthesize.d.ts.map +1 -0
- package/dist/skills/synthesize.js +241 -0
- package/dist/skills/synthesize.js.map +1 -0
- package/dist/subagents/events.d.ts +20 -0
- package/dist/subagents/events.d.ts.map +1 -0
- package/dist/subagents/events.js +146 -0
- package/dist/subagents/events.js.map +1 -0
- package/dist/subagents/registry.d.ts +40 -0
- package/dist/subagents/registry.d.ts.map +1 -0
- package/dist/subagents/registry.js +42 -0
- package/dist/subagents/registry.js.map +1 -0
- package/dist/subagents/run-child.d.ts +43 -0
- package/dist/subagents/run-child.d.ts.map +1 -0
- package/dist/subagents/run-child.js +276 -0
- package/dist/subagents/run-child.js.map +1 -0
- package/dist/subagents/spawn.d.ts +8 -0
- package/dist/subagents/spawn.d.ts.map +1 -0
- package/dist/subagents/spawn.js +8 -0
- package/dist/subagents/spawn.js.map +1 -0
- package/dist/subagents/tools.d.ts +4 -0
- package/dist/subagents/tools.d.ts.map +1 -0
- package/dist/subagents/tools.js +13 -0
- package/dist/subagents/tools.js.map +1 -0
- package/dist/subagents.d.ts +3 -0
- package/dist/subagents.d.ts.map +1 -0
- package/dist/subagents.js +3 -0
- package/dist/subagents.js.map +1 -0
- package/dist/surfaces/host.d.ts +53 -0
- package/dist/surfaces/host.d.ts.map +1 -0
- package/dist/surfaces/host.js +177 -0
- package/dist/surfaces/host.js.map +1 -0
- package/dist/tunnel/localhost.d.ts +7 -0
- package/dist/tunnel/localhost.d.ts.map +1 -0
- package/dist/tunnel/localhost.js +21 -0
- package/dist/tunnel/localhost.js.map +1 -0
- package/dist/usage-stats.d.ts +42 -0
- package/dist/usage-stats.d.ts.map +1 -0
- package/dist/usage-stats.js +83 -0
- package/dist/usage-stats.js.map +1 -0
- package/dist/view/default-renderer.d.ts +7 -0
- package/dist/view/default-renderer.d.ts.map +1 -0
- package/dist/view/default-renderer.js +14 -0
- package/dist/view/default-renderer.js.map +1 -0
- package/dist/view/parse.d.ts +7 -0
- package/dist/view/parse.d.ts.map +1 -0
- package/dist/view/parse.js +479 -0
- package/dist/view/parse.js.map +1 -0
- package/package.json +61 -0
- package/src/events/factory.ts +27 -0
- package/src/events/log.test.ts +470 -0
- package/src/events/log.ts +256 -0
- package/src/index.ts +102 -0
- package/src/logger.ts +46 -0
- package/src/permissions/engine.test.ts +206 -0
- package/src/permissions/engine.ts +247 -0
- package/src/permissions/resolvers.test.ts +40 -0
- package/src/permissions/resolvers.ts +17 -0
- package/src/plugins/discovery.test.ts +115 -0
- package/src/plugins/discovery.ts +124 -0
- package/src/plugins/host-options.ts +73 -0
- package/src/plugins/host-view-tunnel.test.ts +86 -0
- package/src/plugins/host.test.ts +434 -0
- package/src/plugins/host.ts +327 -0
- package/src/plugins/lifecycle.test.ts +187 -0
- package/src/plugins/lifecycle.ts +136 -0
- package/src/plugins/loader.ts +79 -0
- package/src/plugins/package-requirements.ts +50 -0
- package/src/plugins/registry-kinds.test.ts +162 -0
- package/src/plugins/registry-kinds.ts +210 -0
- package/src/plugins/toposort.test.ts +66 -0
- package/src/plugins/toposort.ts +69 -0
- package/src/preferences.test.ts +105 -0
- package/src/preferences.ts +84 -0
- package/src/registries/active-backend-registry.ts +142 -0
- package/src/registries/active-def-registry.test.ts +103 -0
- package/src/registries/active-def-registry.ts +97 -0
- package/src/registries/agents.ts +18 -0
- package/src/registries/cache-strategies.ts +14 -0
- package/src/registries/channels.ts +42 -0
- package/src/registries/commands.test.ts +71 -0
- package/src/registries/commands.ts +78 -0
- package/src/registries/compactors.ts +14 -0
- package/src/registries/def-map-registry.test.ts +67 -0
- package/src/registries/def-map-registry.ts +59 -0
- package/src/registries/embedders.test.ts +54 -0
- package/src/registries/embedders.ts +18 -0
- package/src/registries/isolators.test.ts +43 -0
- package/src/registries/isolators.ts +38 -0
- package/src/registries/modes.test.ts +66 -0
- package/src/registries/modes.ts +80 -0
- package/src/registries/providers.test.ts +50 -0
- package/src/registries/providers.ts +97 -0
- package/src/registries/semantics.test.ts +117 -0
- package/src/registries/skills.ts +87 -0
- package/src/registries/surfaces.ts +18 -0
- package/src/registries/synthesizers.test.ts +63 -0
- package/src/registries/synthesizers.ts +36 -0
- package/src/registries/tools.test.ts +142 -0
- package/src/registries/tools.ts +145 -0
- package/src/registries/transcribers.test.ts +70 -0
- package/src/registries/transcribers.ts +19 -0
- package/src/registries/tunnel-providers.test.ts +30 -0
- package/src/registries/tunnel-providers.ts +14 -0
- package/src/registries/view-renderers.test.ts +68 -0
- package/src/registries/view-renderers.ts +15 -0
- package/src/registries/workflow-executors.test.ts +53 -0
- package/src/registries/workflow-executors.ts +15 -0
- package/src/requirements.targetinfo.test.ts +70 -0
- package/src/requirements.test.ts +111 -0
- package/src/requirements.ts +262 -0
- package/src/run-turn.test.ts +178 -0
- package/src/run-turn.ts +156 -0
- package/src/session-runtime.ts +75 -0
- package/src/session.test.ts +216 -0
- package/src/session.ts +491 -0
- package/src/sessions/persistence.flush.test.ts +100 -0
- package/src/sessions/persistence.ready.test.ts +91 -0
- package/src/sessions/persistence.test.ts +274 -0
- package/src/sessions/persistence.ts +498 -0
- package/src/setup-agent.test.ts +72 -0
- package/src/setup-agent.ts +189 -0
- package/src/skills/index.ts +14 -0
- package/src/skills/loader.test.ts +102 -0
- package/src/skills/loader.ts +114 -0
- package/src/skills/parse.test.ts +59 -0
- package/src/skills/parse.ts +11 -0
- package/src/skills/synthesize-draft.test.ts +79 -0
- package/src/skills/synthesize-draft.ts +46 -0
- package/src/skills/synthesize.test.ts +170 -0
- package/src/skills/synthesize.ts +304 -0
- package/src/subagents/events.test.ts +56 -0
- package/src/subagents/events.ts +194 -0
- package/src/subagents/registry.ts +62 -0
- package/src/subagents/run-child.test.ts +185 -0
- package/src/subagents/run-child.ts +425 -0
- package/src/subagents/spawn.ts +12 -0
- package/src/subagents/tools.ts +19 -0
- package/src/subagents.ts +2 -0
- package/src/surfaces/host.test.ts +330 -0
- package/src/surfaces/host.ts +192 -0
- package/src/tunnel/localhost.test.ts +22 -0
- package/src/tunnel/localhost.ts +21 -0
- package/src/usage-stats.test.ts +77 -0
- package/src/usage-stats.ts +119 -0
- package/src/view/default-renderer.test.ts +29 -0
- package/src/view/default-renderer.ts +14 -0
- package/src/view/parse-extended.test.ts +255 -0
- package/src/view/parse.test.ts +232 -0
- package/src/view/parse.ts +520 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MoxxyRequirement,
|
|
3
|
+
Plugin,
|
|
4
|
+
PluginHostHandle,
|
|
5
|
+
RequirementCheck,
|
|
6
|
+
RequirementIssue,
|
|
7
|
+
ResolvedPluginManifest,
|
|
8
|
+
} from '@moxxy/sdk';
|
|
9
|
+
import { discoverPlugins } from './discovery.js';
|
|
10
|
+
import { toposortPluginManifests, PluginCycleError } from './toposort.js';
|
|
11
|
+
import { REGISTRY_KINDS, type RegistryNameRecord } from './registry-kinds.js';
|
|
12
|
+
|
|
13
|
+
// PluginHostOptions + PluginLoader live in ./host-options.js (a leaf shared with
|
|
14
|
+
// registry-kinds.ts to avoid an import cycle). Imported for local use and
|
|
15
|
+
// re-exported so existing `import { PluginHostOptions } from './host.js'` call
|
|
16
|
+
// sites keep working.
|
|
17
|
+
import type { PluginHostOptions, PluginLoader } from './host-options.js';
|
|
18
|
+
export type { PluginHostOptions, PluginLoader };
|
|
19
|
+
|
|
20
|
+
export interface RegisterStaticOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Static requirements to enforce before registration. Mirrors the
|
|
23
|
+
* `moxxy.requirements` field a discovered plugin's package.json would
|
|
24
|
+
* carry; passed explicitly here because statically-imported builtins
|
|
25
|
+
* don't go through `discoverPlugins()`.
|
|
26
|
+
*/
|
|
27
|
+
readonly requirements?: ReadonlyArray<MoxxyRequirement>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type PluginSkipSource = 'static' | 'discovered';
|
|
31
|
+
export type PluginSkipReason = 'unmet_requirements' | 'load_error';
|
|
32
|
+
|
|
33
|
+
export interface PluginSkipRecord {
|
|
34
|
+
readonly pluginName: string;
|
|
35
|
+
readonly source: PluginSkipSource;
|
|
36
|
+
readonly reason: PluginSkipReason;
|
|
37
|
+
readonly message: string;
|
|
38
|
+
readonly packageName?: string;
|
|
39
|
+
readonly issues?: ReadonlyArray<RequirementIssue>;
|
|
40
|
+
readonly hints: ReadonlyArray<string>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class PluginRequirementError extends Error {
|
|
44
|
+
constructor(
|
|
45
|
+
readonly pluginName: string,
|
|
46
|
+
readonly check: RequirementCheck,
|
|
47
|
+
) {
|
|
48
|
+
super(
|
|
49
|
+
check.issues
|
|
50
|
+
.filter((issue) => !issue.requirement.optional)
|
|
51
|
+
.map((issue) => issue.message)
|
|
52
|
+
.join('; '),
|
|
53
|
+
);
|
|
54
|
+
this.name = 'PluginRequirementError';
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface LoadedRecord extends RegistryNameRecord {
|
|
59
|
+
readonly plugin: Plugin;
|
|
60
|
+
readonly manifest?: ResolvedPluginManifest;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class PluginHost implements PluginHostHandle {
|
|
64
|
+
private readonly loaded = new Map<string, LoadedRecord>();
|
|
65
|
+
private readonly skipped = new Map<string, PluginSkipRecord>();
|
|
66
|
+
|
|
67
|
+
constructor(private readonly opts: PluginHostOptions) {}
|
|
68
|
+
|
|
69
|
+
list(): ReadonlyArray<{
|
|
70
|
+
name: string;
|
|
71
|
+
version: string;
|
|
72
|
+
loaded: boolean;
|
|
73
|
+
kinds: ReadonlyArray<string>;
|
|
74
|
+
}> {
|
|
75
|
+
return [...this.loaded.values()].map((r) => ({
|
|
76
|
+
name: r.plugin.name,
|
|
77
|
+
version: r.plugin.version,
|
|
78
|
+
loaded: true,
|
|
79
|
+
kinds: contributionKinds(r),
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
listSkipped(): ReadonlyArray<PluginSkipRecord> {
|
|
84
|
+
return [...this.skipped.values()];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
registerStatic(plugin: Plugin, opts: RegisterStaticOptions = {}): void {
|
|
88
|
+
if (this.loaded.has(plugin.name)) {
|
|
89
|
+
throw new Error(`Plugin already registered: ${plugin.name}`);
|
|
90
|
+
}
|
|
91
|
+
this.assertRequirementsReady(plugin, opts.requirements, 'static');
|
|
92
|
+
const record = this.applyPlugin(plugin);
|
|
93
|
+
this.loaded.set(plugin.name, record);
|
|
94
|
+
this.clearSkip(plugin.name);
|
|
95
|
+
this.opts.requirements.registerPlugin(plugin.name, plugin.version);
|
|
96
|
+
this.refreshDispatcher();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
registerDiscovered(plugin: Plugin, manifest: ResolvedPluginManifest): void {
|
|
100
|
+
// Key the loaded map by the PACKAGE name — not the plugin's declared
|
|
101
|
+
// `name` — so it lines up with `discoverAndLoad`'s dedupe check, `reload`'s
|
|
102
|
+
// `wanted` set, and `unload(packageName)` callers (self-update, config,
|
|
103
|
+
// plugins-admin). Keying by `plugin.name` silently broke all three whenever
|
|
104
|
+
// a plugin's declared name differed from its package name (re-load throws,
|
|
105
|
+
// reload unloads everything, unload no-ops).
|
|
106
|
+
if (this.loaded.has(manifest.packageName)) {
|
|
107
|
+
throw new Error(`Plugin already registered: ${manifest.packageName}`);
|
|
108
|
+
}
|
|
109
|
+
this.assertRequirementsReady(plugin, manifest.requirements, 'discovered', manifest);
|
|
110
|
+
const record = this.applyPlugin(plugin, manifest);
|
|
111
|
+
this.loaded.set(manifest.packageName, record);
|
|
112
|
+
this.clearSkip(plugin.name);
|
|
113
|
+
this.clearSkip(manifest.packageName);
|
|
114
|
+
// Register the requirement under the PACKAGE name, not the declared
|
|
115
|
+
// plugin.name. `kind:'plugin'` requirements are resolved by package name —
|
|
116
|
+
// toposort keys `byPackage` by `manifest.packageName`, so the readiness
|
|
117
|
+
// gate (`requirements.plugins.get(name)`) must agree. Keying by plugin.name
|
|
118
|
+
// here meant a dependent's `{kind:'plugin', name:<packageName>}` would pass
|
|
119
|
+
// toposort ordering but fail the readiness gate whenever a plugin's
|
|
120
|
+
// declared name differed from its package name (host.ts documents that they
|
|
121
|
+
// can differ).
|
|
122
|
+
this.opts.requirements.registerPlugin(manifest.packageName, plugin.version);
|
|
123
|
+
this.refreshDispatcher();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async discoverAndLoad(extraPaths?: ReadonlyArray<string>): Promise<ReadonlyArray<Plugin>> {
|
|
127
|
+
const manifests = await discoverPlugins({
|
|
128
|
+
cwd: this.opts.cwd,
|
|
129
|
+
logger: this.opts.logger,
|
|
130
|
+
extraPaths,
|
|
131
|
+
});
|
|
132
|
+
const loaded: Plugin[] = [];
|
|
133
|
+
const loader = this.opts.loader;
|
|
134
|
+
if (!loader) {
|
|
135
|
+
this.opts.logger.warn(
|
|
136
|
+
'PluginHost.discoverAndLoad called without a loader; static plugins only. Provide a loader (e.g. jiti loader) to enable dynamic discovery.',
|
|
137
|
+
);
|
|
138
|
+
return loaded;
|
|
139
|
+
}
|
|
140
|
+
let ordered: ReadonlyArray<ResolvedPluginManifest>;
|
|
141
|
+
try {
|
|
142
|
+
ordered = toposortPluginManifests(manifests);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (err instanceof PluginCycleError) {
|
|
145
|
+
this.opts.logger.warn('PluginHost: requirement cycle, falling back to unsorted order', {
|
|
146
|
+
cycle: err.cycle,
|
|
147
|
+
});
|
|
148
|
+
ordered = manifests;
|
|
149
|
+
} else {
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const manifest of ordered) {
|
|
154
|
+
if (this.loaded.has(manifest.packageName)) continue;
|
|
155
|
+
// Honor a runtime/config disable: never load a package the user turned off.
|
|
156
|
+
if (this.opts.isDisabled?.(manifest.packageName)) continue;
|
|
157
|
+
try {
|
|
158
|
+
const plugin = await loader.load(manifest);
|
|
159
|
+
this.registerDiscovered(plugin, manifest);
|
|
160
|
+
loaded.push(plugin);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (err instanceof PluginRequirementError) {
|
|
163
|
+
this.opts.logger.warn('PluginHost: skipped plugin due to unmet requirements', {
|
|
164
|
+
package: manifest.packageName,
|
|
165
|
+
plugin: err.pluginName,
|
|
166
|
+
err: err.message,
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
this.recordLoadError(manifest.packageName, 'discovered', manifest.packageName, err);
|
|
171
|
+
this.opts.logger.warn('PluginHost: failed to load plugin', {
|
|
172
|
+
package: manifest.packageName,
|
|
173
|
+
err: err instanceof Error ? err.message : String(err),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
this.refreshDispatcher();
|
|
178
|
+
return loaded;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async unload(name: string): Promise<void> {
|
|
182
|
+
const record = this.loaded.get(name);
|
|
183
|
+
if (!record) return;
|
|
184
|
+
// Iterate REGISTRY_KINDS in its declared order — the exact set + order the
|
|
185
|
+
// original hand-written unregister sequence used — so register/unregister
|
|
186
|
+
// stay in lockstep and a new kind is one table entry, not edits here.
|
|
187
|
+
for (const kind of REGISTRY_KINDS) {
|
|
188
|
+
for (const recordedName of record[kind.recordField]) kind.unregister(this.opts, recordedName);
|
|
189
|
+
}
|
|
190
|
+
this.loaded.delete(name);
|
|
191
|
+
// Unregister under the SAME key registration used: package name for
|
|
192
|
+
// discovered plugins (see registerDiscovered), declared name for statics.
|
|
193
|
+
this.opts.requirements.unregisterPlugin(record.manifest?.packageName ?? record.plugin.name);
|
|
194
|
+
this.refreshDispatcher();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async reload(): Promise<void> {
|
|
198
|
+
this.opts.logger.info('PluginHost.reload(): rescanning plugins');
|
|
199
|
+
// Reuse the same discovery roots the initial load used (incl. the user
|
|
200
|
+
// plugin dirs) for BOTH the "wanted" scan and the re-load. Omitting them
|
|
201
|
+
// would mark user plugins as not-wanted (→ unloaded) and never re-add them.
|
|
202
|
+
const manifests = await discoverPlugins({
|
|
203
|
+
cwd: this.opts.cwd,
|
|
204
|
+
logger: this.opts.logger,
|
|
205
|
+
...(this.opts.userPaths ? { extraPaths: this.opts.userPaths } : {}),
|
|
206
|
+
});
|
|
207
|
+
// A disabled package is excluded from `wanted`, so the loop below unloads it
|
|
208
|
+
// if currently loaded and discoverAndLoad won't bring it back.
|
|
209
|
+
const wanted = new Set(
|
|
210
|
+
manifests
|
|
211
|
+
.filter((m) => !this.opts.isDisabled?.(m.packageName))
|
|
212
|
+
.map((m) => m.packageName),
|
|
213
|
+
);
|
|
214
|
+
for (const [name] of [...this.loaded]) {
|
|
215
|
+
// Statically-registered builtins have no manifest; never unload them on
|
|
216
|
+
// reload — they aren't discovered from disk so they'd never come back.
|
|
217
|
+
if (this.loaded.get(name)?.manifest && !wanted.has(name)) await this.unload(name);
|
|
218
|
+
}
|
|
219
|
+
await this.discoverAndLoad(this.opts.userPaths);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private applyPlugin(plugin: Plugin, manifest?: ResolvedPluginManifest): LoadedRecord {
|
|
223
|
+
// Snapshot the contributed names BEFORE registering (same as the original
|
|
224
|
+
// two-phase order), keyed by REGISTRY_KINDS field. Building the names from
|
|
225
|
+
// the same table the registration loop uses keeps the two in lockstep.
|
|
226
|
+
const names = {} as Record<keyof RegistryNameRecord, ReadonlyArray<string>>;
|
|
227
|
+
for (const kind of REGISTRY_KINDS) {
|
|
228
|
+
names[kind.recordField] = kind.defs(plugin).map((def) => kind.nameOf(def));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Register in REGISTRY_KINDS order — the exact set + order the original
|
|
232
|
+
// hand-written register sequence used (incl. viewRenderers/tunnelProviders
|
|
233
|
+
// via `replace`).
|
|
234
|
+
for (const kind of REGISTRY_KINDS) {
|
|
235
|
+
for (const def of kind.defs(plugin)) kind.register(this.opts, def);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
plugin,
|
|
240
|
+
manifest,
|
|
241
|
+
...(names as RegistryNameRecord),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private assertRequirementsReady(
|
|
246
|
+
plugin: Plugin,
|
|
247
|
+
requirements: ReadonlyArray<MoxxyRequirement> | undefined,
|
|
248
|
+
source: PluginSkipSource = 'static',
|
|
249
|
+
manifest?: ResolvedPluginManifest,
|
|
250
|
+
): void {
|
|
251
|
+
if (!requirements || requirements.length === 0) return;
|
|
252
|
+
const check = this.opts.requirements.check(requirements);
|
|
253
|
+
if (!check.ready) {
|
|
254
|
+
this.recordRequirementSkip(plugin, source, manifest, check);
|
|
255
|
+
throw new PluginRequirementError(plugin.name, check);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private refreshDispatcher(): void {
|
|
260
|
+
this.opts.dispatcher.setPlugins([...this.loaded.values()].map((r) => r.plugin));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private recordRequirementSkip(
|
|
264
|
+
plugin: Plugin,
|
|
265
|
+
source: PluginSkipSource,
|
|
266
|
+
manifest: ResolvedPluginManifest | undefined,
|
|
267
|
+
check: RequirementCheck,
|
|
268
|
+
): void {
|
|
269
|
+
const blocking = check.issues.filter((issue) => !issue.requirement.optional);
|
|
270
|
+
this.skipped.set(skipKey(plugin.name), {
|
|
271
|
+
pluginName: plugin.name,
|
|
272
|
+
source,
|
|
273
|
+
reason: 'unmet_requirements',
|
|
274
|
+
message: blocking.map((issue) => issue.message).join('; '),
|
|
275
|
+
...(manifest ? { packageName: manifest.packageName } : {}),
|
|
276
|
+
issues: check.issues,
|
|
277
|
+
hints: blocking.flatMap((issue) => issue.hint ? [issue.hint] : []),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private recordLoadError(
|
|
282
|
+
pluginName: string,
|
|
283
|
+
source: PluginSkipSource,
|
|
284
|
+
packageName: string | undefined,
|
|
285
|
+
err: unknown,
|
|
286
|
+
): void {
|
|
287
|
+
this.skipped.set(skipKey(pluginName), {
|
|
288
|
+
pluginName,
|
|
289
|
+
source,
|
|
290
|
+
reason: 'load_error',
|
|
291
|
+
message: err instanceof Error ? err.message : String(err),
|
|
292
|
+
...(packageName ? { packageName } : {}),
|
|
293
|
+
hints: [],
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private clearSkip(pluginName: string): void {
|
|
298
|
+
this.skipped.delete(skipKey(pluginName));
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function skipKey(pluginName: string): string {
|
|
303
|
+
return pluginName;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** The contribution categories a loaded plugin actually registered, in a
|
|
307
|
+
* stable order — lets a UI group plugins by kind (tabs in the picker). */
|
|
308
|
+
function contributionKinds(r: LoadedRecord): ReadonlyArray<string> {
|
|
309
|
+
const kinds: string[] = [];
|
|
310
|
+
if (r.providerNames.length) kinds.push('provider');
|
|
311
|
+
if (r.modeNames.length) kinds.push('mode');
|
|
312
|
+
if (r.channelNames.length) kinds.push('channel');
|
|
313
|
+
if (r.surfaceNames.length) kinds.push('surface');
|
|
314
|
+
if (r.embedderNames.length) kinds.push('embedder');
|
|
315
|
+
if (r.transcriberNames.length) kinds.push('transcriber');
|
|
316
|
+
if (r.synthesizerNames.length) kinds.push('synthesizer');
|
|
317
|
+
if (r.isolatorNames.length) kinds.push('isolator');
|
|
318
|
+
if (r.compactorNames.length) kinds.push('compactor');
|
|
319
|
+
if (r.cacheStrategyNames.length) kinds.push('cacheStrategy');
|
|
320
|
+
if (r.viewRendererNames.length) kinds.push('viewRenderer');
|
|
321
|
+
if (r.tunnelProviderNames.length) kinds.push('tunnelProvider');
|
|
322
|
+
if (r.workflowExecutorNames.length) kinds.push('workflowExecutor');
|
|
323
|
+
if (r.agentNames.length) kinds.push('agent');
|
|
324
|
+
if (r.commandNames.length) kinds.push('command');
|
|
325
|
+
if (r.toolNames.length) kinds.push('tool');
|
|
326
|
+
return kinds;
|
|
327
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { definePlugin, asSessionId, asTurnId, asToolCallId } from '@moxxy/sdk';
|
|
3
|
+
import type {
|
|
4
|
+
AppContext,
|
|
5
|
+
ToolCallContext,
|
|
6
|
+
ToolResultContext,
|
|
7
|
+
ToolResultEvent,
|
|
8
|
+
TurnContext,
|
|
9
|
+
} from '@moxxy/sdk';
|
|
10
|
+
import { silentLogger } from '../logger.js';
|
|
11
|
+
import { HookDispatcherImpl } from './lifecycle.js';
|
|
12
|
+
|
|
13
|
+
const sid = asSessionId('s');
|
|
14
|
+
const tid = asTurnId('t');
|
|
15
|
+
|
|
16
|
+
const appCtx: AppContext = {
|
|
17
|
+
sessionId: sid,
|
|
18
|
+
cwd: '/tmp',
|
|
19
|
+
log: { length: 0, at: () => undefined, slice: () => [], ofType: () => [], byTurn: () => [], toJSON: () => [] },
|
|
20
|
+
env: {},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const turnCtx: TurnContext = { ...appCtx, turnId: tid, iteration: 0 };
|
|
24
|
+
|
|
25
|
+
const callCtx: ToolCallContext = {
|
|
26
|
+
...turnCtx,
|
|
27
|
+
call: { callId: asToolCallId('c'), name: 'X', input: {} },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('HookDispatcherImpl', () => {
|
|
31
|
+
it('fires onInit in plugin registration order', async () => {
|
|
32
|
+
const order: string[] = [];
|
|
33
|
+
const a = definePlugin({ name: 'a', hooks: { onInit: () => void order.push('a') } });
|
|
34
|
+
const b = definePlugin({ name: 'b', hooks: { onInit: () => void order.push('b') } });
|
|
35
|
+
const d = new HookDispatcherImpl({ logger: silentLogger });
|
|
36
|
+
d.setPlugins([b, a]);
|
|
37
|
+
await d.dispatchInit(appCtx);
|
|
38
|
+
expect(order).toEqual(['b', 'a']);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('short-circuits onToolCall on first deny', async () => {
|
|
42
|
+
const calls: string[] = [];
|
|
43
|
+
const allow = definePlugin({
|
|
44
|
+
name: 'allow',
|
|
45
|
+
hooks: {
|
|
46
|
+
onToolCall: () => {
|
|
47
|
+
calls.push('allow');
|
|
48
|
+
return { action: 'allow' };
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
const deny = definePlugin({
|
|
53
|
+
name: 'deny',
|
|
54
|
+
hooks: {
|
|
55
|
+
onToolCall: () => {
|
|
56
|
+
calls.push('deny');
|
|
57
|
+
return { action: 'deny', reason: 'no' };
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const after = definePlugin({
|
|
62
|
+
name: 'after',
|
|
63
|
+
hooks: { onToolCall: () => void calls.push('after') },
|
|
64
|
+
});
|
|
65
|
+
const d = new HookDispatcherImpl({ logger: silentLogger });
|
|
66
|
+
d.setPlugins([allow, deny, after]);
|
|
67
|
+
const verdict = await d.dispatchToolCall(callCtx);
|
|
68
|
+
expect(verdict.action).toBe('deny');
|
|
69
|
+
expect(calls).toEqual(['allow', 'deny']);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('lets a later deny override an earlier rewrite (deny precedence)', async () => {
|
|
73
|
+
const calls: string[] = [];
|
|
74
|
+
const rewrite = definePlugin({
|
|
75
|
+
name: 'rewrite',
|
|
76
|
+
hooks: {
|
|
77
|
+
onToolCall: () => {
|
|
78
|
+
calls.push('rewrite');
|
|
79
|
+
return { action: 'rewrite', input: { redacted: true } };
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
const deny = definePlugin({
|
|
84
|
+
name: 'deny',
|
|
85
|
+
hooks: {
|
|
86
|
+
onToolCall: () => {
|
|
87
|
+
calls.push('deny');
|
|
88
|
+
return { action: 'deny', reason: 'blocked' };
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const d = new HookDispatcherImpl({ logger: silentLogger });
|
|
93
|
+
d.setPlugins([rewrite, deny]);
|
|
94
|
+
const verdict = await d.dispatchToolCall(callCtx);
|
|
95
|
+
expect(verdict).toEqual({ action: 'deny', reason: 'blocked' });
|
|
96
|
+
expect(calls).toEqual(['rewrite', 'deny']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('threads onToolResult through plugins so each sees the prior output', async () => {
|
|
100
|
+
const baseResult: ToolResultEvent = {
|
|
101
|
+
id: 'e1' as never,
|
|
102
|
+
seq: 0,
|
|
103
|
+
ts: 0,
|
|
104
|
+
sessionId: sid,
|
|
105
|
+
turnId: tid,
|
|
106
|
+
source: 'tool' as never,
|
|
107
|
+
type: 'tool_result',
|
|
108
|
+
callId: asToolCallId('c'),
|
|
109
|
+
ok: true,
|
|
110
|
+
output: 'raw',
|
|
111
|
+
};
|
|
112
|
+
const resultCtx: ToolResultContext = { ...turnCtx, result: baseResult };
|
|
113
|
+
const seen: unknown[] = [];
|
|
114
|
+
const a = definePlugin({
|
|
115
|
+
name: 'a',
|
|
116
|
+
hooks: {
|
|
117
|
+
onToolResult: (ctx) => {
|
|
118
|
+
seen.push(ctx.result.output);
|
|
119
|
+
return { ...ctx.result, output: `${ctx.result.output as string}-a` };
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
const b = definePlugin({
|
|
124
|
+
name: 'b',
|
|
125
|
+
hooks: {
|
|
126
|
+
onToolResult: (ctx) => {
|
|
127
|
+
// B must observe A's mutation, not the original.
|
|
128
|
+
seen.push(ctx.result.output);
|
|
129
|
+
return { ...ctx.result, output: `${ctx.result.output as string}-b` };
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
const d = new HookDispatcherImpl({ logger: silentLogger });
|
|
134
|
+
d.setPlugins([a, b]);
|
|
135
|
+
const final = await d.dispatchToolResult(resultCtx);
|
|
136
|
+
expect(seen).toEqual(['raw', 'raw-a']);
|
|
137
|
+
expect(final.output).toBe('raw-a-b');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('pipelines onBeforeProviderCall', async () => {
|
|
141
|
+
const p1 = definePlugin({
|
|
142
|
+
name: 'p1',
|
|
143
|
+
hooks: {
|
|
144
|
+
onBeforeProviderCall: (req) => ({ ...req, system: (req.system ?? '') + '[p1]' }),
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
const p2 = definePlugin({
|
|
148
|
+
name: 'p2',
|
|
149
|
+
hooks: {
|
|
150
|
+
onBeforeProviderCall: (req) => ({ ...req, system: (req.system ?? '') + '[p2]' }),
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
const d = new HookDispatcherImpl({ logger: silentLogger });
|
|
154
|
+
d.setPlugins([p2, p1]);
|
|
155
|
+
const out = await d.dispatchBeforeProviderCall(
|
|
156
|
+
{ model: 'm', messages: [], system: '' },
|
|
157
|
+
turnCtx,
|
|
158
|
+
);
|
|
159
|
+
expect(out.system).toBe('[p2][p1]');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('hasEventHooks reflects whether any plugin declares onEvent', () => {
|
|
163
|
+
const d = new HookDispatcherImpl({ logger: silentLogger });
|
|
164
|
+
expect(d.hasEventHooks()).toBe(false);
|
|
165
|
+
d.setPlugins([definePlugin({ name: 'noop', hooks: { onInit: () => {} } })]);
|
|
166
|
+
expect(d.hasEventHooks()).toBe(false);
|
|
167
|
+
d.setPlugins([definePlugin({ name: 'listener', hooks: { onEvent: () => {} } })]);
|
|
168
|
+
expect(d.hasEventHooks()).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('logs and continues when a hook throws', async () => {
|
|
172
|
+
const failed = vi.fn();
|
|
173
|
+
const bad = definePlugin({
|
|
174
|
+
name: 'bad',
|
|
175
|
+
hooks: {
|
|
176
|
+
onInit: () => {
|
|
177
|
+
throw new Error('nope');
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
const good = definePlugin({ name: 'good', hooks: { onInit: () => {} } });
|
|
182
|
+
const d = new HookDispatcherImpl({ logger: silentLogger, onHookFailed: failed });
|
|
183
|
+
d.setPlugins([bad, good]);
|
|
184
|
+
await d.dispatchInit(appCtx);
|
|
185
|
+
expect(failed).toHaveBeenCalledOnce();
|
|
186
|
+
});
|
|
187
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppContext,
|
|
3
|
+
HookDispatcher,
|
|
4
|
+
LifecycleHooks,
|
|
5
|
+
MoxxyEvent,
|
|
6
|
+
Plugin,
|
|
7
|
+
ProviderRequest,
|
|
8
|
+
ToolCallContext,
|
|
9
|
+
ToolCallVerdict,
|
|
10
|
+
ToolResultContext,
|
|
11
|
+
ToolResultEvent,
|
|
12
|
+
TurnContext,
|
|
13
|
+
} from '@moxxy/sdk';
|
|
14
|
+
import type { Logger } from '../logger.js';
|
|
15
|
+
|
|
16
|
+
export interface DispatcherOptions {
|
|
17
|
+
readonly hookTimeoutMs?: number;
|
|
18
|
+
readonly logger: Logger;
|
|
19
|
+
readonly onHookFailed?: (err: Error, plugin: string, hook: keyof LifecycleHooks) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface PluginEntry {
|
|
23
|
+
readonly plugin: Plugin;
|
|
24
|
+
readonly hooks: LifecycleHooks;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class HookDispatcherImpl implements HookDispatcher {
|
|
28
|
+
private entries: PluginEntry[] = [];
|
|
29
|
+
private readonly timeoutMs: number;
|
|
30
|
+
private readonly logger: Logger;
|
|
31
|
+
private readonly onHookFailed?: DispatcherOptions['onHookFailed'];
|
|
32
|
+
|
|
33
|
+
constructor(opts: DispatcherOptions) {
|
|
34
|
+
this.timeoutMs = opts.hookTimeoutMs ?? 5_000;
|
|
35
|
+
this.logger = opts.logger;
|
|
36
|
+
this.onHookFailed = opts.onHookFailed;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
setPlugins(plugins: ReadonlyArray<Plugin>): void {
|
|
40
|
+
this.entries = plugins.map((plugin) => ({ plugin, hooks: plugin.hooks ?? {} }));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Whether any registered plugin declares an `onEvent` hook. Lets the
|
|
45
|
+
* per-event fan-out skip building an AppContext (which clones the whole
|
|
46
|
+
* `process.env`) when no plugin would consume it — the common case.
|
|
47
|
+
*/
|
|
48
|
+
hasEventHooks(): boolean {
|
|
49
|
+
return this.entries.some((e) => typeof e.hooks.onEvent === 'function');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async dispatchInit(ctx: AppContext): Promise<void> {
|
|
53
|
+
for (const e of this.entries) await this.safe(e, 'onInit', () => e.hooks.onInit?.(ctx));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async dispatchTurnStart(ctx: TurnContext): Promise<void> {
|
|
57
|
+
for (const e of this.entries) await this.safe(e, 'onTurnStart', () => e.hooks.onTurnStart?.(ctx));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async dispatchBeforeProviderCall(req: ProviderRequest, ctx: TurnContext): Promise<ProviderRequest> {
|
|
61
|
+
let current = req;
|
|
62
|
+
for (const e of this.entries) {
|
|
63
|
+
const next = await this.safe(e, 'onBeforeProviderCall', () =>
|
|
64
|
+
e.hooks.onBeforeProviderCall?.(current, ctx),
|
|
65
|
+
);
|
|
66
|
+
if (next) current = next;
|
|
67
|
+
}
|
|
68
|
+
return current;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async dispatchToolCall(ctx: ToolCallContext): Promise<ToolCallVerdict> {
|
|
72
|
+
let verdict: ToolCallVerdict = { action: 'allow' };
|
|
73
|
+
for (const e of this.entries) {
|
|
74
|
+
const next = await this.safe(e, 'onToolCall', () => e.hooks.onToolCall?.(ctx));
|
|
75
|
+
if (!next) continue;
|
|
76
|
+
if (next.action === 'deny') return next;
|
|
77
|
+
if (next.action === 'rewrite') verdict = next;
|
|
78
|
+
}
|
|
79
|
+
return verdict;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async dispatchToolResult(ctx: ToolResultContext): Promise<ToolResultEvent> {
|
|
83
|
+
let result = ctx.result;
|
|
84
|
+
for (const e of this.entries) {
|
|
85
|
+
const next = await this.safe(e, 'onToolResult', () =>
|
|
86
|
+
e.hooks.onToolResult?.({ ...ctx, result }),
|
|
87
|
+
);
|
|
88
|
+
if (next) result = next;
|
|
89
|
+
}
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async dispatchEvent(event: MoxxyEvent, ctx: AppContext): Promise<void> {
|
|
94
|
+
for (const e of this.entries) {
|
|
95
|
+
await this.safe(e, 'onEvent', () => e.hooks.onEvent?.(event, ctx));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async dispatchTurnEnd(ctx: TurnContext): Promise<void> {
|
|
100
|
+
for (const e of this.entries) await this.safe(e, 'onTurnEnd', () => e.hooks.onTurnEnd?.(ctx));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async dispatchShutdown(ctx: AppContext): Promise<void> {
|
|
104
|
+
for (const e of this.entries) await this.safe(e, 'onShutdown', () => e.hooks.onShutdown?.(ctx));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private async safe<T>(
|
|
108
|
+
entry: PluginEntry,
|
|
109
|
+
hook: keyof LifecycleHooks,
|
|
110
|
+
fn: () => T | Promise<T> | void | Promise<void>,
|
|
111
|
+
): Promise<T | undefined> {
|
|
112
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
113
|
+
try {
|
|
114
|
+
const p = Promise.resolve(fn() as Promise<T | undefined | void>);
|
|
115
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
116
|
+
timer = setTimeout(
|
|
117
|
+
() => reject(new Error(`Hook ${hook} on ${entry.plugin.name} timed out`)),
|
|
118
|
+
this.timeoutMs,
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
// Attach a no-op rejection handler to the timeout promise so if the
|
|
122
|
+
// fast path wins, the timer's eventual rejection is swallowed
|
|
123
|
+
// silently rather than surfacing as an unhandled rejection.
|
|
124
|
+
timeout.catch(() => undefined);
|
|
125
|
+
const winner = await Promise.race([p, timeout]);
|
|
126
|
+
return winner as T | undefined;
|
|
127
|
+
} catch (err) {
|
|
128
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
129
|
+
this.logger.warn('hook failed', { plugin: entry.plugin.name, hook, err: error.message });
|
|
130
|
+
this.onHookFailed?.(error, entry.plugin.name, hook);
|
|
131
|
+
return undefined;
|
|
132
|
+
} finally {
|
|
133
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|