@jini-ai/agent-plugins 0.4.2 → 0.4.4

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/CHANGELOG.md CHANGED
@@ -1,7 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.4 — 2026-10-12 — Agent Plugins 1.1.0
4
+
5
+ - `parseAgentPluginManifest()` / `parseAgentPluginMcpConfig()` (and so the installer and MCP
6
+ projection) select the grammar by `$schema` and accept Agent Plugins 1.1.0 identifiers alongside
7
+ 1.0.0. 1.1.0 changes only the version identifiers, so both map to the same validator; 1.0.0
8
+ behavior is unchanged. When a `plugin.json` is supplied, `mcp.json` must target the same version
9
+ (§10).
10
+ - Forward-compatible within major 1 (owner decision): an unknown 1.x (e.g. 1.2.0) is validated with
11
+ the newest known (1.1.0) grammar and reports one warning naming the version. A different major
12
+ (2.x, 0.x) is refused as an unsupported major version; a non-canonical `$schema` is refused with
13
+ the accepted identifiers listed.
14
+
3
15
  ## 0.4.2
4
16
 
17
+ - Use shared plugin identity metadata with a required `@jini-ai/core` peer at `^0.4.2`. Repair identity-lock publication syntax and isolate unreadable or invalid installed manifests with warnings so one damaged install cannot block healthy installs or bundled seeding.
18
+
5
19
  ### Patch Changes
6
20
 
7
21
  - Tovu clean-up release (2026-10-08): code Tovu moved into Jini, plus the Jini clean-up (see the commit log since 0.4.1).
package/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  > **Leon, this is you from the past. Do not try to merge `plugins` and `agent-plugins`.**
4
4
 
5
- Support for **Agent Plugins**, an open, vendor-neutral spec (v1.0.0, published 2026-08-06) for
6
- bundling Agent Skills and MCP servers into one portable directory. Published by a Technical
7
- Steering Committee with maintainers from Amazon, Cursor, Google, Microsoft, OpenAI, and Vercel.
5
+ Support for **Agent Plugins**, an open, vendor-neutral spec (v1.0.0, published 2026-08-06; v1.1.0 and
6
+ later 1.x manifests are also accepted) for bundling Agent Skills and MCP servers into one portable
7
+ directory. Published by a Technical Steering Committee with maintainers from Amazon, Cursor, Google, Microsoft, OpenAI, and Vercel.
8
8
  Source: https://developers.googleblog.com/agent-plugins-package-your-skills-tools-and-more/
9
9
 
10
10
  If you are an agent reading this cold: this document is written so you don't need the source
@@ -3,6 +3,10 @@
3
3
  * ships as — written by `seed-bundled.ts` on every boot, read by the surfaces that have to pick one
4
4
  * installed package for a plugin id.
5
5
  *
6
+ * Current install policy (frpid, 2026-10-09): successful publication prunes superseded revisions.
7
+ * The rationale below describes retained legacy stores and interrupted cleanup. This ledger still
8
+ * selects the build's authoritative digest there; the ledger itself never deletes package bytes.
9
+ *
6
10
  * ---------------------------------------------------------------------------
7
11
  * The gap this closes
8
12
  * ---------------------------------------------------------------------------
@@ -5,7 +5,7 @@ export type { InstalledAgentPlugin, InstalledAgentPluginSkill } from "./types.js
5
5
  /** One extraction/install failure reason. A caller (an admin route, a future marketplace installer)
6
6
  * branches on `code` rather than parsing `message` — this codebase's own convention
7
7
  * (`PluginLoadError.reason` in `plugin-runtime/loader.ts` is the direct precedent). */
8
- export type AgentPluginInstallErrorCode = "ARCHIVE_TOO_LARGE" | "DIGEST_MISMATCH" | "TOO_MANY_ENTRIES" | "UNSAFE_ENTRY_PATH" | "SYMLINK_ENTRY_REJECTED" | "UNSUPPORTED_ENTRY_KIND" | "DUPLICATE_ENTRY" | "FILE_TOO_LARGE" | "DECOMPRESSION_BOMB" | "TOTAL_SIZE_EXCEEDED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "PUBLISH_FAILED";
8
+ export type AgentPluginInstallErrorCode = "PLUGIN_ID_CONFLICT" | "ARCHIVE_TOO_LARGE" | "DIGEST_MISMATCH" | "TOO_MANY_ENTRIES" | "UNSAFE_ENTRY_PATH" | "SYMLINK_ENTRY_REJECTED" | "UNSUPPORTED_ENTRY_KIND" | "DUPLICATE_ENTRY" | "FILE_TOO_LARGE" | "DECOMPRESSION_BOMB" | "TOTAL_SIZE_EXCEEDED" | "MANIFEST_MISSING" | "MANIFEST_INVALID" | "PUBLISH_FAILED";
9
9
  export declare class AgentPluginInstallError extends Error {
10
10
  readonly code: AgentPluginInstallErrorCode;
11
11
  constructor({ code, message }: {
@@ -1,3 +1,6 @@
1
+ import { listInstalledPackageRoots } from "./installed-package-roots.js";
2
+ import { createUninstallModule } from "./uninstall.js";
3
+ import { readPluginIdentity, assertPluginIdentityAvailable } from "@jini-ai/core/plugin-directory";
1
4
  /**
2
5
  * @file `installAgentPlugin()` — content-addressed, adversarially-hardened extraction of one Agent
3
6
  * Plugin (agent-plugins.org) archive into one workspace's own `AgentPluginWorkspaceLayout.packages`.
@@ -95,7 +98,7 @@ function buildModule(ports) {
95
98
  const rename = ports.filesystem.rename.bind(ports.filesystem);
96
99
  const rm = ports.filesystem.rm.bind(ports.filesystem);
97
100
  const stat = ports.filesystem.stat.bind(ports.filesystem);
98
- const parseAgentPluginManifest = (value) => parseAgentPluginManifestValue({ value });
101
+ const parseAgentPluginManifest = (value) => parseAgentPluginManifestValue({ value }, { identityNamespace: ports.extensionNamespace });
99
102
  const { assertContainedOnDisk, normalizePackageEntryPath } = createPackagePathsModule(ports);
100
103
  const LIMITS = {
101
104
  maxArchiveBytes: 32 * 1024 * 1024,
@@ -103,6 +106,63 @@ function buildModule(ports) {
103
106
  maxFileBytes: 16 * 1024 * 1024,
104
107
  maxTotalExtractedBytes: 64 * 1024 * 1024,
105
108
  };
109
+ /** Publisher ids are metadata, never filesystem segments. The workspace identity lock
110
+ * serializes installs under different local names competing for one qualified id.
111
+ * @complexity O(d) manifest reads for d installed revisions; no executable imports. */
112
+ async function assertIdentityAvailable(workspaceRoot, candidate) {
113
+ // Installed roots are canonical paths; compare against the same root even when the host
114
+ // supplied an OS alias (macOS /var -> /private/var). Containment still rejects escaped links.
115
+ workspaceRoot = await ports.filesystem.realpath(workspaceRoot);
116
+ for (const root of await listInstalledPackageRoots({ ports, workspaceRoot })) {
117
+ const manifestPath = path.join(root.packageRoot, 'plugin.json');
118
+ await persistentState.assertOwnedPath({ workspaceRoot, entryPath: path.relative(workspaceRoot, manifestPath) });
119
+ let raw;
120
+ let identity;
121
+ try {
122
+ raw = JSON.parse(await readFile(manifestPath, 'utf8'));
123
+ identity = readPluginIdentity({ manifest: raw, name: root.localId, namespace: ports.extensionNamespace });
124
+ }
125
+ catch {
126
+ // A damaged unrelated install must not block fresh installs or bundled seeding.
127
+ ports.onEvent?.({ event: 'agent-plugin-identity-warning', message: `Plugin '${root.localId}': installed identity manifest is unreadable or invalid; omitted from identity admission.` });
128
+ continue;
129
+ }
130
+ try {
131
+ assertPluginIdentityAvailable({ candidate: { id: candidate.id, localId: candidate.pluginId, family: 'agent-plugin' },
132
+ installed: [{ ...identity, localId: root.localId, family: 'agent-plugin', version: typeof raw.version === 'string' ? raw.version : null, enabled: false }] });
133
+ }
134
+ catch {
135
+ throw new AgentPluginInstallError({ code: 'PLUGIN_ID_CONFLICT', message: 'The publisher-qualified plugin id conflicts with an installed package.' });
136
+ }
137
+ }
138
+ }
139
+ /** Retires only this plugin's superseded revisions after accepted publication, under the same
140
+ * identity/plugin locks. Persistent data, learned facts and notes never enter this operation.
141
+ * Reuse uninstall's frozen-tree staging so an interrupted removal is no longer a live digest.
142
+ * There is no previous-revision rollback slot in this lifecycle; failed publication keeps all
143
+ * prior bytes. Cleanup faults warn without turning an accepted install into a failed install.
144
+ * @complexity O(d + f), d workspace revisions, f files in this plugin's superseded packages.
145
+ */
146
+ async function pruneSupersededRevisions({ workspaceRoot, installed }, _optional = {}) {
147
+ try {
148
+ // Resolve lazily: uninstall's read-side index uses this install module for manifest indexing.
149
+ const removal = createUninstallModule(ports);
150
+ for (const root of await listInstalledPackageRoots({ ports, workspaceRoot })) {
151
+ if (root.localId !== installed.pluginId || root.archiveDigest === installed.archiveDigest)
152
+ continue;
153
+ try {
154
+ const staged = await removal.stageForRemoval(path.dirname(root.packageRoot), root.packageRoot);
155
+ await removal.removeFrozenPackageTree(staged.quarantined);
156
+ }
157
+ catch {
158
+ ports.onEvent?.({ event: 'agent-plugin-revision-prune-warning', message: `Plugin '${installed.pluginId}': a superseded revision could not be pruned.` });
159
+ }
160
+ }
161
+ }
162
+ catch {
163
+ ports.onEvent?.({ event: 'agent-plugin-revision-prune-warning', message: `Plugin '${installed.pluginId}': superseded revisions could not be scanned for pruning.` });
164
+ }
165
+ }
106
166
  /** Exposes `LIMITS.maxArchiveBytes` for a caller outside this module — concretely, a test asserting
107
167
  * `@jini-ai/agent-plugins/lifecycle`'s fetch cap constant stays pinned to this one. The local fetch-archive.ts fork was deleted; see the package's fetch module
108
168
  * for why it duplicates the number instead of importing it (no runtime dependency on `install.ts`);
@@ -150,43 +210,56 @@ function buildModule(ports) {
150
210
  const indexed = await indexInstalledRoot(extractionRoot, digest);
151
211
  if (optional.previewOnly)
152
212
  return indexed;
153
- return await persistentState.withPluginLock({ workspaceRoot: workspaceLayout.root, pluginId: indexed.pluginId, run: async () => {
154
- // Layout B needs the manifest id before choosing the package destination. Dedup still
155
- // skips publication and freezing, after one bounded staging extraction.
156
- // Owner decision 2026-10-04: learned facts begin empty for each site; author guidance stays
157
- // in package docs. Package extension files never seed learned memory or the user's notes.
158
- // Content-addressed dedup, scoped to THIS workspace's own tree (`workspaceLayout` was just
159
- // resolved above, atomically, from this same `workspaceId`): identical bytes were already
160
- // extracted, verified, and frozen by a prior install of this same workspace's — never
161
- // re-extracted for a different workspace, by construction, since a different workspace's
162
- // `workspaceLayout.packages` is a different path entirely. one bounded staging extraction precedes this check.
163
- // Publish BEFORE freezing permissions, not after: `rename()` moves `extractionRoot` as a single
164
- // directory-entry operation, and on this filesystem renaming an already-read-only (0o555)
165
- // directory itself fails EACCES (observed, not merely theoretical — see the regression test this
166
- // ordering fixed). Freezing after publish also closes a smaller race for free: nothing outside
167
- // this function can observe the package at its live path while it is still writable, because the
168
- // live path does not exist until `publish` returns.
169
- const publishPackage = async () => {
170
- // The manifest identifies the plugin only after bounded staging extraction (Layout B).
171
- const pluginId = indexed.pluginId;
172
- const packagesDir = workspaceLayout.pluginPackagesDir({ pluginId });
173
- await persistentState.assertOwnedPath({ workspaceRoot: workspaceLayout.root, entryPath: path.relative(workspaceLayout.root, packagesDir) });
174
- await mkdir(packagesDir, { recursive: true, mode: 0o700 });
175
- const finalRoot = await assertContainedOnDisk(packagesDir, digest);
176
- for (const directory of [workspaceLayout.pluginDataDir({ pluginId }),
177
- workspaceLayout.pluginMemoryDir({ pluginId, kind: 'learned' }),
178
- workspaceLayout.pluginMemoryDir({ pluginId, kind: 'notes' })]) {
179
- await persistentState.assertOwnedPath({ workspaceRoot: workspaceLayout.root, entryPath: path.relative(workspaceLayout.root, directory) });
180
- await mkdir(directory, { recursive: true, mode: 0o700 });
181
- }
182
- if (await isRealDirectory(finalRoot))
183
- return indexInstalledRoot(finalRoot, digest);
184
- await publish(extractionRoot, finalRoot);
185
- await freezeTree(finalRoot, executablePaths);
186
- /** Absolute, read-only (frozen) path to the extracted package root. */
187
- return { ...indexed, packageRoot: finalRoot };
188
- };
189
- return optional.publishGuard ? optional.publishGuard({ plugin: indexed, publish: publishPackage }, {}) : publishPackage();
213
+ return await persistentState.withIdentityLock({ workspaceRoot: workspaceLayout.root, run: async () => {
214
+ await assertIdentityAvailable(workspaceLayout.root, indexed);
215
+ const publishWithinPluginLock = () => persistentState.withPluginLock({ workspaceRoot: workspaceLayout.root, pluginId: indexed.pluginId, run: async () => {
216
+ // Layout B needs the manifest id before choosing the package destination. Dedup still
217
+ // skips publication and freezing, after one bounded staging extraction.
218
+ // Owner decision 2026-10-04: learned facts begin empty for each site; author guidance stays
219
+ // in package docs. Package extension files never seed learned memory or the user's notes.
220
+ // Content-addressed dedup, scoped to THIS workspace's own tree (`workspaceLayout` was just
221
+ // resolved above, atomically, from this same `workspaceId`): identical bytes were already
222
+ // extracted, verified, and frozen by a prior install of this same workspace's — never
223
+ // re-extracted for a different workspace, by construction, since a different workspace's
224
+ // `workspaceLayout.packages` is a different path entirely. one bounded staging extraction precedes this check.
225
+ // Publish BEFORE freezing permissions, not after: `rename()` moves `extractionRoot` as a single
226
+ // directory-entry operation, and on this filesystem renaming an already-read-only (0o555)
227
+ // directory itself fails EACCES (observed, not merely theoretical — see the regression test this
228
+ // ordering fixed). Freezing after publish also closes a smaller race for free: nothing outside
229
+ // this function can observe the package at its live path while it is still writable, because the
230
+ // live path does not exist until `publish` returns.
231
+ let publishedPlugin;
232
+ const publishPackage = async () => {
233
+ // The manifest identifies the plugin only after bounded staging extraction (Layout B).
234
+ const pluginId = indexed.pluginId;
235
+ const packagesDir = workspaceLayout.pluginPackagesDir({ pluginId });
236
+ await persistentState.assertOwnedPath({ workspaceRoot: workspaceLayout.root, entryPath: path.relative(workspaceLayout.root, packagesDir) });
237
+ await mkdir(packagesDir, { recursive: true, mode: 0o700 });
238
+ const finalRoot = await assertContainedOnDisk(packagesDir, digest);
239
+ for (const directory of [workspaceLayout.pluginDataDir({ pluginId }),
240
+ workspaceLayout.pluginMemoryDir({ pluginId, kind: 'learned' }),
241
+ workspaceLayout.pluginMemoryDir({ pluginId, kind: 'notes' })]) {
242
+ await persistentState.assertOwnedPath({ workspaceRoot: workspaceLayout.root, entryPath: path.relative(workspaceLayout.root, directory) });
243
+ await mkdir(directory, { recursive: true, mode: 0o700 });
244
+ }
245
+ if (await isRealDirectory(finalRoot)) {
246
+ publishedPlugin = await indexInstalledRoot(finalRoot, digest);
247
+ return publishedPlugin;
248
+ }
249
+ await publish(extractionRoot, finalRoot);
250
+ await freezeTree(finalRoot, executablePaths);
251
+ /** Absolute, read-only (frozen) path to the extracted package root. */
252
+ publishedPlugin = { ...indexed, packageRoot: finalRoot };
253
+ return publishedPlugin;
254
+ };
255
+ const installed = await (optional.publishGuard ? optional.publishGuard({ plugin: indexed, publish: publishPackage }, {}) : publishPackage());
256
+ if (publishedPlugin)
257
+ await pruneSupersededRevisions({ workspaceRoot: workspaceLayout.root, installed: publishedPlugin });
258
+ return installed;
259
+ } });
260
+ if (ports.identityAdmission)
261
+ return ports.identityAdmission({ id: indexed.id, localId: indexed.pluginId, run: publishWithinPluginLock }, {});
262
+ return publishWithinPluginLock();
190
263
  } });
191
264
  }
192
265
  finally {
@@ -379,6 +452,8 @@ function buildModule(ports) {
379
452
  throw new AgentPluginInstallError({ code: "MANIFEST_INVALID", message: `plugin.json failed validation: ${parsed.errors.join("; ")}` });
380
453
  }
381
454
  const files = [];
455
+ for (const message of parsed.warnings)
456
+ ports.onEvent?.({ event: 'agent-plugin-identity-warning', message });
382
457
  const skills = [];
383
458
  async function walk(absolute, prefix) {
384
459
  for (const entry of await readdir(absolute, { withFileTypes: true })) {
@@ -401,6 +476,8 @@ function buildModule(ports) {
401
476
  await walk(packageRoot, "");
402
477
  return {
403
478
  pluginId: parsed.manifest.name,
479
+ id: parsed.manifest.id,
480
+ provides: parsed.manifest.provides,
404
481
  version: parsed.manifest.version,
405
482
  ...( /**
406
483
  * `plugin.json`'s own `description`/`keywords`/`author`/`license` (`manifest.ts`'s
@@ -0,0 +1,16 @@
1
+ import type { AgentPluginLifecyclePorts } from './ports.js';
2
+ export declare const INSTALLED_PLUGIN_DIGEST_PATTERN: RegExp;
3
+ export interface InstalledPackageRoot {
4
+ readonly localId: string;
5
+ readonly archiveDigest: string;
6
+ readonly packageRoot: string;
7
+ }
8
+ /** Enumerates contained installed revisions; missing stores are empty. A corrupt slot is
9
+ * isolated, matching the full installed-plugin index's existing recovery contract.
10
+ * @complexity O(d) directory/containment operations for d revisions; no executable imports.
11
+ */
12
+ export declare function listInstalledPackageRoots({ ports, workspaceRoot }: {
13
+ ports: AgentPluginLifecyclePorts;
14
+ workspaceRoot: string;
15
+ }, _optional?: Record<string, never>): Promise<readonly InstalledPackageRoot[]>;
16
+ //# sourceMappingURL=installed-package-roots.d.ts.map
@@ -0,0 +1,55 @@
1
+ /** The installed-package walk shared by full indexing and identity admission. Reusing this
2
+ * traversal preserves the same digest grammar, memory-folder exclusion and containment checks. */
3
+ import path from 'node:path';
4
+ import { createPersistentStateModule } from './persistent-state.js';
5
+ import { createPackagePathsModule } from './package-paths.js';
6
+ export const INSTALLED_PLUGIN_DIGEST_PATTERN = /^[a-f0-9]{64}$/;
7
+ /** Enumerates contained installed revisions; missing stores are empty. A corrupt slot is
8
+ * isolated, matching the full installed-plugin index's existing recovery contract.
9
+ * @complexity O(d) directory/containment operations for d revisions; no executable imports.
10
+ */
11
+ export async function listInstalledPackageRoots({ ports, workspaceRoot }, _optional = {}) {
12
+ const persistentState = createPersistentStateModule(ports);
13
+ const { assertContainedOnDisk } = createPackagePathsModule(ports);
14
+ const readdir = ports.filesystem.readdir.bind(ports.filesystem);
15
+ let folders;
16
+ try {
17
+ folders = await readdir(workspaceRoot, { withFileTypes: true });
18
+ }
19
+ catch (error) {
20
+ if (isEnoent(error))
21
+ return [];
22
+ throw error;
23
+ }
24
+ const roots = [];
25
+ for (const folder of folders) {
26
+ if (!folder.isDirectory() || !/^[a-z0-9]+(?:[-.][a-z0-9]+)*$/.test(folder.name) || folder.name.length > 64)
27
+ continue;
28
+ const packagesDir = path.join(workspaceRoot, folder.name, 'package', 'sha256');
29
+ let entries;
30
+ try {
31
+ await persistentState.assertOwnedPath({ workspaceRoot, entryPath: path.relative(workspaceRoot, packagesDir) });
32
+ entries = await readdir(packagesDir, { withFileTypes: true });
33
+ }
34
+ catch {
35
+ continue;
36
+ }
37
+ for (const entry of entries) {
38
+ if (!entry.isDirectory() || !INSTALLED_PLUGIN_DIGEST_PATTERN.test(entry.name))
39
+ continue;
40
+ try {
41
+ const packageRoot = await assertContainedOnDisk(workspaceRoot, path.relative(workspaceRoot, path.join(packagesDir, entry.name)));
42
+ roots.push({ localId: folder.name, archiveDigest: entry.name, packageRoot });
43
+ }
44
+ catch {
45
+ // One corrupt package never hides another plugin. Memory-only folders are not installs.
46
+ }
47
+ }
48
+ }
49
+ return roots;
50
+ }
51
+ /** Only a genuinely missing store is empty; other filesystem faults propagate unchanged. */
52
+ function isEnoent(error) {
53
+ return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
54
+ }
55
+ //# sourceMappingURL=installed-package-roots.js.map
@@ -1,62 +1,8 @@
1
- /**
2
- * @file `parseAgentPluginManifest()` / `parseAgentPluginMcpConfig()` — the Agent Plugins v1.0.0
3
- * `plugin.json` / `mcp.json` grammar (agent-plugins.org/specification), verified against the live
4
- * spec rather than inferred from an example manifest.
5
- *
6
- * Deliberately a SEPARATE, smaller validator from `src/features/plugin-runtime/manifest.ts`'s
7
- * `validateManifest()`. That file validates `.host-plugin`'s own tarball format — `integrity`
8
- * (per-file SHA-256), `sdkRange`, `tier`, an exactly-three-token capability vocabulary, a single
9
- * `content.entry.beforeSave` hook. This file validates the OPEN standard's `plugin.json`, which
10
- * shares none of those fields and defines no execution/trust/capability model at all — see this
11
- * feature's own header comment in `install.ts` for why the two formats cannot be unified.
12
- *
13
- * Pinned v1.0.0 rules from agent-plugins.org/specification:
14
- * - `plugin.json`'s `name`: 1-64 chars, lowercase alphanumeric/hyphen/period only, must start and
15
- * end alphanumeric, no `--` or `..` consecutive delimiters (§5.5). Valid: `my-plugin`,
16
- * `acme.tools`, `lint3r`, `a`. Invalid: `My-Plugin`, `-start`, `has--double`.
17
- * - `plugin.json`'s `$schema` MUST equal `https://agent-plugins.org/schemas/1.0.0/plugin.schema.json`
18
- * for this pinned version; `mcp.json`'s MUST equal
19
- * `https://agent-plugins.org/schemas/1.0.0/mcp.schema.json`.
20
- * - Unknown top-level `plugin.json` fields produce warnings but do not block loading — this module
21
- * returns them as `warnings`, never as a rejection reason.
22
- * - `mcp.json`'s top-level shape is `{ "$schema": ..., "mcpServers": { "<server-id>": {...} } }` —
23
- * `mcpServers` MUST be an object whose member names identify servers.
24
- * - Each server entry MUST carry a `type` discriminator matching one of three CLOSED variants.
25
- * Launching requires the actual transport config, not just a server id:
26
- * - `stdio`: requires `command` (non-empty string); optional `args` (string[]), `env`
27
- * (string-valued object — MUST NOT set `PLUGIN_ROOT`/`PLUGIN_DATA`, which the spec reserves),
28
- * `cwd` (plugin-relative path).
29
- * - `streamable-http` / `sse`: requires `url` (non-empty string); optional `headers`
30
- * (string-valued object).
31
- * - **The spec defines NO auth-related field on a server entry at all.** Verbatim from
32
- * `/specification`: "Agent Plugins v1 defines no OAuth configuration or portable
33
- * credential-reference fields. Authorization discovery, user interaction, and credential storage
34
- * are client-managed." A plugin therefore cannot declare "this server needs OAuth" using any
35
- * spec-defined field — the host is the client the spec defers that decision to. This module accepts
36
- * the host-specific metadata in `plugin.json`'s `extensions.<host-namespace>.mcpServers[serverId]`, keeping
37
- * `mcp.json` transport entries closed per §7.2.1. Hosts may translate legacy auth and sign-in defaults in MCP
38
- * entries through readServerMetadata for existing packages, but those cannot supply operator read trust.
39
- * `authMode: "oauth" | "none"` selects client auth; absent defaults to "none".
40
- * `defaultTools: { allow?, write?, read? }` names sign-in grants and reviewed read tools.
41
- * `write` requires `allow` and must be its subset; `read` must be a subset when `allow` is
42
- * present. A read-only declaration without `allow` leaves sign-in grants unchanged.
43
- * Remote hints may veto a read declaration but never supply it. A third,
44
- * `tokenAuth: { helpUrl, probeUrl }`, offers a pasted access token as the sign-in fallback
45
- * (`access-token-tool.ts`). A fourth, `renamedTools: { oldName: newName }`, carries saved tool
46
- * selections across a vendor's tool rename (`apply-tool-renames.ts`). A malformed per-server entry
47
- * (wrong/missing `type`, missing required field) does not fail the whole file — see
48
- * `ParseAgentPluginMcpConfigResult`'s own doc for the fail-open contract this preserves.
49
- *
50
- * Architectural role:
51
- * Pure, no-I/O parsing over an already-`JSON.parse`d value — mirrors `plugin-runtime/manifest.ts`'s
52
- * own "receives a JS value, never a file path or raw bytes" contract. `install.ts` is the only
53
- * caller that reads the file and hands the parsed value here.
54
- */
55
1
  export { validatePluginManifest, validateMcpManifest } from '../manifest.js';
56
2
  export type { PluginManifest, McpManifest, McpServerEntry } from '../manifest.js';
57
3
  import type { PluginManifestAuthor } from '../manifest.js';
58
4
  export type { PluginManifestAuthor } from '../manifest.js';
59
- /** Agent Plugins 1.0.0 parsing, with application metadata confined to an explicit namespace. */
5
+ /** Agent Plugins 1.0.0/1.1.0 parsing, with application metadata confined to an explicit namespace. */
60
6
  export type AgentPluginServerMetadataReader = (required: {
61
7
  readonly serverId: string;
62
8
  readonly value: Readonly<Record<string, unknown>>;
@@ -70,6 +16,9 @@ export interface AgentPluginManifest {
70
16
  readonly license?: (string) | undefined;
71
17
  readonly keywords?: (readonly string[]) | undefined;
72
18
  readonly extensions?: (Readonly<Record<string, unknown>>) | undefined;
19
+ /** Normalized publisher identity, independent of name/storage key and version. */
20
+ readonly id?: string | undefined;
21
+ readonly provides?: readonly string[] | undefined;
73
22
  }
74
23
  export type ParseAgentPluginManifestResult = {
75
24
  readonly ok: true;
@@ -80,7 +29,7 @@ export type ParseAgentPluginManifestResult = {
80
29
  readonly errors: readonly string[];
81
30
  };
82
31
  /**
83
- * Validates an already-`JSON.parse`d `plugin.json` value against the v1.0.0 grammar.
32
+ * Validates an already-`JSON.parse`d `plugin.json` value against the 1.0.0/1.1.0 grammar.
84
33
  *
85
34
  * @throws Nothing — every failure is reported via the returned `errors`, matching this codebase's
86
35
  * own convention for expected validation outcomes (`plugin-runtime/manifest.ts`,
@@ -89,9 +38,11 @@ export type ParseAgentPluginManifestResult = {
89
38
  */
90
39
  export declare function parseAgentPluginManifest({ value }: {
91
40
  readonly value: unknown;
41
+ }, { identityNamespace }?: {
42
+ readonly identityNamespace?: string;
92
43
  }): ParseAgentPluginManifestResult;
93
44
  /**
94
- * The three closed transport variants `mcp.schema.json` v1.0.0 defines. See this file's header for
45
+ * The three closed transport variants `mcp.schema.json` 1.0.0/1.1.0 defines. See this file's header for
95
46
  * the verified field shape of each.
96
47
  */
97
48
  export declare const MCP_SERVER_TRANSPORTS: readonly ["stdio", "streamable-http", "sse"];
@@ -183,9 +134,10 @@ export type ParseAgentPluginMcpConfigResult = {
183
134
  readonly errors: readonly string[];
184
135
  };
185
136
  /**
186
- * Validates an already-`JSON.parse`d `mcp.json` value against the v1.0.0 top-level shape:
137
+ * Validates an already-`JSON.parse`d `mcp.json` value against the 1.0.0/1.1.0 top-level shape:
187
138
  * `{ "$schema": ..., "mcpServers": { "<server-id>": {...} } }`, then validates each declared
188
- * server's own transport shape (see this file's header for the three variants).
139
+ * server's own transport shape (see this file's header for the three variants). When
140
+ * `pluginManifest` declares a supported version, `mcp.json` must declare the same one (§10).
189
141
  *
190
142
  * @throws Nothing — see {@link parseAgentPluginManifest}.
191
143
  * @complexity O(s) in the number of declared servers.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @file `parseAgentPluginManifest()` / `parseAgentPluginMcpConfig()` — the Agent Plugins v1.0.0
2
+ * @file `parseAgentPluginManifest()` / `parseAgentPluginMcpConfig()` — the Agent Plugins 1.0.0/1.1.0
3
3
  * `plugin.json` / `mcp.json` grammar (agent-plugins.org/specification), verified against the live
4
4
  * spec rather than inferred from an example manifest.
5
5
  *
@@ -10,13 +10,15 @@
10
10
  * shares none of those fields and defines no execution/trust/capability model at all — see this
11
11
  * feature's own header comment in `install.ts` for why the two formats cannot be unified.
12
12
  *
13
- * Pinned v1.0.0 rules from agent-plugins.org/specification:
13
+ * Pinned 1.0.0/1.1.0 rules from agent-plugins.org/specification:
14
14
  * - `plugin.json`'s `name`: 1-64 chars, lowercase alphanumeric/hyphen/period only, must start and
15
15
  * end alphanumeric, no `--` or `..` consecutive delimiters (§5.5). Valid: `my-plugin`,
16
16
  * `acme.tools`, `lint3r`, `a`. Invalid: `My-Plugin`, `-start`, `has--double`.
17
- * - `plugin.json`'s `$schema` MUST equal `https://agent-plugins.org/schemas/1.0.0/plugin.schema.json`
18
- * for this pinned version; `mcp.json`'s MUST equal
19
- * `https://agent-plugins.org/schemas/1.0.0/mcp.schema.json`.
17
+ * - `plugin.json`'s `$schema` MUST be a recognized canonical identifier
18
+ * (`https://agent-plugins.org/schemas/<version>/plugin.schema.json`); `mcp.json`'s likewise
19
+ * (`.../<version>/mcp.schema.json`), and when both are present their versions MUST match (§10).
20
+ * 1.0.0 and 1.1.0 are recognized; an unknown 1.x is read with the newest known grammar plus one
21
+ * warning; any other major is refused — see `KNOWN_SPEC_VERSIONS`.
20
22
  * - Unknown top-level `plugin.json` fields produce warnings but do not block loading — this module
21
23
  * returns them as `warnings`, never as a rejection reason.
22
24
  * - `mcp.json`'s top-level shape is `{ "$schema": ..., "mcpServers": { "<server-id>": {...} } }` —
@@ -52,13 +54,47 @@
52
54
  * own "receives a JS value, never a file path or raw bytes" contract. `install.ts` is the only
53
55
  * caller that reads the file and hands the parsed value here.
54
56
  */
57
+ import { readPluginIdentity, validatePluginIdentity } from "@jini-ai/core/plugin-directory";
55
58
  export { validatePluginManifest, validateMcpManifest } from '../manifest.js';
56
59
  /**
57
- * The one plugin.json schema version this loader understands. A manifest declaring any other
58
- * value is rejected outright rather than guessed at: pinning the grammar must survive spec churn.
60
+ * The Agent Plugins versions whose schemas have been diffed against this file's grammar. 1.1.0's
61
+ * spec text and both schemas differ from 1.0.0 only in the version identifiers, so both map to the
62
+ * one grammar below — §5.1 allows mapping explicitly recognized compatible versions to one
63
+ * implementation. Add a version here only after diffing its schemas against this grammar.
59
64
  */
60
- const PLUGIN_SCHEMA_1_0_0 = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json";
61
- const MCP_SCHEMA_1_0_0 = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
65
+ const KNOWN_SPEC_VERSIONS = ["1.0.0", "1.1.0"];
66
+ const SUPPORTED_MAJOR = "1";
67
+ /**
68
+ * Owner decision (2026-10-12): any other 1.x is accepted with the newest known grammar and one
69
+ * warning, so a minor spec release does not lock out packages that target it. This goes beyond
70
+ * §5.1, which says a client MUST reject a version it does not explicitly recognize. Unknown fields
71
+ * stay tolerated only where the known grammar already allows them (unknown top-level fields warn,
72
+ * `extensions` is opaque). A different major is refused, since majors may break the grammar.
73
+ */
74
+ const SCHEMA_URL_PATTERN = /^https:\/\/agent-plugins\.org\/schemas\/((\d+)\.\d+\.\d+)\/(plugin|mcp)\.schema\.json$/;
75
+ /** The canonical `$schema` identifier for one spec version's `plugin.json` or `mcp.json`. */
76
+ function schemaUrl(version, file) {
77
+ return `https://agent-plugins.org/schemas/${version}/${file}.schema.json`;
78
+ }
79
+ /**
80
+ * Reads the spec version `value` names as `file`'s canonical schema identifier: a known version,
81
+ * an unknown 1.x (with its warning), or the error for a different major or a non-canonical value.
82
+ */
83
+ function readSpecVersion(value, file) {
84
+ const match = typeof value === "string" ? SCHEMA_URL_PATTERN.exec(value) : null;
85
+ if (!match || match[3] !== file) {
86
+ const accepted = KNOWN_SPEC_VERSIONS.map((version) => `'${schemaUrl(version, file)}'`).join(" or ");
87
+ return { error: `${file}.json '$schema' must be ${accepted} (Agent Plugins ${KNOWN_SPEC_VERSIONS.join(", ")}, or another ${SUPPORTED_MAJOR}.x), got '${String(value)}'` };
88
+ }
89
+ const version = match[1];
90
+ if (match[2] !== SUPPORTED_MAJOR) {
91
+ return { error: `${file}.json targets Agent Plugins ${version}, an unsupported major version (supported: ${SUPPORTED_MAJOR}.x)` };
92
+ }
93
+ if (KNOWN_SPEC_VERSIONS.includes(version))
94
+ return { version };
95
+ const newest = KNOWN_SPEC_VERSIONS[KNOWN_SPEC_VERSIONS.length - 1];
96
+ return { version, warning: `${file}.json targets Agent Plugins ${version}, newer than this loader knows; validated with the ${newest} grammar` };
97
+ }
62
98
  /**
63
99
  * §5.5's grammar as one regex: alnum runs separated by single `-`/`.` delimiters. This shape alone
64
100
  * guarantees start/end are alphanumeric (the regex can only start and end on `[a-z0-9]+`) AND that
@@ -84,13 +120,6 @@ function isJsonObject(value) {
84
120
  function coerceOptionalString(value) {
85
121
  return typeof value === "string" ? value : undefined;
86
122
  }
87
- /**
88
- * `value` narrowed to a `string[]` (non-string entries dropped), or `undefined` when `value`
89
- * itself isn't an array — `keywords`'s own coercion.
90
- */
91
- function coerceStringArray(value) {
92
- return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : undefined;
93
- }
94
123
  function parseManifestAuthor(value) {
95
124
  if (typeof value === 'string')
96
125
  return value; // Existing installed manifests may carry a string.
@@ -120,22 +149,24 @@ function resolveManifestName(raw) {
120
149
  return { name };
121
150
  }
122
151
  /**
123
- * Validates an already-`JSON.parse`d `plugin.json` value against the v1.0.0 grammar.
152
+ * Validates an already-`JSON.parse`d `plugin.json` value against the 1.0.0/1.1.0 grammar.
124
153
  *
125
154
  * @throws Nothing — every failure is reported via the returned `errors`, matching this codebase's
126
155
  * own convention for expected validation outcomes (`plugin-runtime/manifest.ts`,
127
156
  * `plugin-runtime/discovery.ts`).
128
157
  * @complexity O(k + n) time and space in the input keys and keyword entries.
129
158
  */
130
- export function parseAgentPluginManifest({ value }) {
159
+ export function parseAgentPluginManifest({ value }, { identityNamespace = "tovu" } = {}) {
131
160
  if (!isJsonObject(value))
132
161
  return { ok: false, errors: ["plugin.json must be a JSON object"] };
133
162
  const raw = value;
134
163
  const errors = [];
135
164
  const warnings = [];
136
- if (raw.$schema !== PLUGIN_SCHEMA_1_0_0) {
137
- errors.push(`plugin.json '$schema' must be '${PLUGIN_SCHEMA_1_0_0}', got '${String(raw.$schema)}'`);
138
- }
165
+ const specVersion = readSpecVersion(raw.$schema, "plugin");
166
+ if (specVersion.error)
167
+ errors.push(specVersion.error);
168
+ else if (specVersion.warning)
169
+ warnings.push(specVersion.warning);
139
170
  if (raw.extensions !== undefined && !isJsonObject(raw.extensions))
140
171
  errors.push("plugin.json extensions must be an object");
141
172
  const nameResult = resolveManifestName(raw);
@@ -145,21 +176,26 @@ export function parseAgentPluginManifest({ value }) {
145
176
  if (!KNOWN_MANIFEST_KEYS.has(key))
146
177
  warnings.push(`unrecognized plugin.json field '${key}' (ignored per spec)`);
147
178
  }
179
+ errors.push(...validatePluginIdentity({ manifest: raw, namespace: identityNamespace }).errors);
148
180
  if (errors.length > 0)
149
181
  return { ok: false, errors };
182
+ const identity = readPluginIdentity({ manifest: raw, namespace: identityNamespace });
183
+ warnings.push(...identity.warnings);
150
184
  const manifest = {
151
185
  name: nameResult.name,
186
+ id: identity.id,
187
+ provides: identity.provides,
152
188
  version: coerceOptionalString(raw.version),
153
189
  description: coerceOptionalString(raw.description),
154
190
  author: parseManifestAuthor(raw.author),
155
191
  license: coerceOptionalString(raw.license),
156
- keywords: coerceStringArray(raw.keywords),
192
+ keywords: raw.keywords === undefined ? undefined : identity.keywords,
157
193
  ...(isJsonObject(raw.extensions) ? { extensions: raw.extensions } : {}),
158
194
  };
159
195
  return { ok: true, manifest, warnings };
160
196
  }
161
197
  /**
162
- * The three closed transport variants `mcp.schema.json` v1.0.0 defines. See this file's header for
198
+ * The three closed transport variants `mcp.schema.json` 1.0.0/1.1.0 defines. See this file's header for
163
199
  * the verified field shape of each.
164
200
  */
165
201
  export const MCP_SERVER_TRANSPORTS = ["stdio", "streamable-http", "sse"];
@@ -349,9 +385,10 @@ function parseMcpServerConfig(value, extension) {
349
385
  return null;
350
386
  }
351
387
  /**
352
- * Validates an already-`JSON.parse`d `mcp.json` value against the v1.0.0 top-level shape:
388
+ * Validates an already-`JSON.parse`d `mcp.json` value against the 1.0.0/1.1.0 top-level shape:
353
389
  * `{ "$schema": ..., "mcpServers": { "<server-id>": {...} } }`, then validates each declared
354
- * server's own transport shape (see this file's header for the three variants).
390
+ * server's own transport shape (see this file's header for the three variants). When
391
+ * `pluginManifest` declares a supported version, `mcp.json` must declare the same one (§10).
355
392
  *
356
393
  * @throws Nothing — see {@link parseAgentPluginManifest}.
357
394
  * @complexity O(s) in the number of declared servers.
@@ -363,8 +400,14 @@ export function parseAgentPluginMcpConfig({ value, extensionNamespace }, optiona
363
400
  return { ok: false, errors: ["mcp.json must be a JSON object"] };
364
401
  const raw = value;
365
402
  const errors = [];
366
- if (raw.$schema !== MCP_SCHEMA_1_0_0) {
367
- errors.push(`mcp.json '$schema' must be '${MCP_SCHEMA_1_0_0}', got '${String(raw.$schema)}'`);
403
+ // plugin.json already reports an unknown-1.x warning; mcp.json adds none of its own.
404
+ const mcpSpec = readSpecVersion(raw.$schema, "mcp");
405
+ const mcpVersion = mcpSpec.version;
406
+ const pluginVersion = isJsonObject(optional.pluginManifest) ? readSpecVersion(optional.pluginManifest.$schema, "plugin").version : undefined;
407
+ if (mcpSpec.error)
408
+ errors.push(mcpSpec.error);
409
+ else if (pluginVersion !== undefined && pluginVersion !== mcpVersion) {
410
+ errors.push(`mcp.json targets Agent Plugins ${mcpVersion} but plugin.json targets ${pluginVersion}; the versions must match`);
368
411
  }
369
412
  const mcpServers = raw.mcpServers;
370
413
  if (typeof mcpServers !== "object" || mcpServers === null || Array.isArray(mcpServers)) {
@@ -1,6 +1,10 @@
1
1
  import type { AgentPluginLifecyclePorts } from './ports.js';
2
2
  /** Reuses the platform lock with injected effects, never age-stealing a living migration. */
3
3
  export declare function createPersistentStateModule(ports: AgentPluginLifecyclePorts, _optional?: Record<string, never>): {
4
+ withIdentityLock: <T>(required: {
5
+ workspaceRoot: string;
6
+ run(): Promise<T>;
7
+ }, _optional?: {}) => Promise<T>;
4
8
  withPluginLock: <T>(required: {
5
9
  workspaceRoot: string;
6
10
  pluginId: string;
@@ -1,4 +1,5 @@
1
1
  import { createPluginMemory, migratePluginLayout, assertPluginStatePath, withPluginStateLock } from '../../core/persistent-state.js';
2
+ import path from 'node:path';
2
3
  import { withFileLock } from '@jini-ai/platform/fs/file-lock';
3
4
  import { createPackagePathsModule } from './package-paths.js';
4
5
  import { parseAgentPluginManifest } from './manifest.js';
@@ -16,6 +17,7 @@ export function createPersistentStateModule(ports, _optional = {}) {
16
17
  }),
17
18
  };
18
19
  return {
20
+ withIdentityLock: (required, _optional = {}) => effects.withLock({ lockPath: path.join(required.workspaceRoot, '.publisher-identity.lock'), run: required.run }),
19
21
  withPluginLock: (required, optional = {}) => withPluginStateLock({ ...effects, ...required }, optional),
20
22
  memory: (required, optional = {}) => createPluginMemory({ ...effects, ...required }, optional),
21
23
  assertOwnedPath: (required, optional = {}) => assertPluginStatePath({ ...effects, ...required }, optional),
@@ -52,6 +52,12 @@ export interface AgentPluginOutboundGuardPort {
52
52
  }): Promise<void>;
53
53
  }
54
54
  export interface AgentPluginLifecyclePorts {
55
+ /** Optional host admission serializes cross-kind identity checking and publication. */
56
+ readonly identityAdmission?: (<T>(required: {
57
+ id: string;
58
+ localId: string;
59
+ run: () => Promise<T>;
60
+ }, optional?: Record<string, never>) => Promise<T>) | undefined;
55
61
  readonly onEvent?: ((required: {
56
62
  readonly event: string;
57
63
  readonly message: string;
@@ -88,6 +94,6 @@ export interface AgentPluginLifecyclePorts {
88
94
  readonly outboundGuard: AgentPluginOutboundGuardPort;
89
95
  }
90
96
  /** Application policy/effects are required; observers and metadata translators are optional. */
91
- export type AgentPluginLifecycleRequired = Omit<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery'>;
92
- export type AgentPluginLifecycleOptional = Pick<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery'>;
97
+ export type AgentPluginLifecycleRequired = Omit<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery' | 'identityAdmission'>;
98
+ export type AgentPluginLifecycleOptional = Pick<AgentPluginLifecyclePorts, 'onEvent' | 'readServerMetadata' | 'migrateBeforeSeed' | 'formatBundledUninstallRecovery' | 'identityAdmission'>;
93
99
  //# sourceMappingURL=ports.d.ts.map
@@ -1,3 +1,4 @@
1
+ import { listInstalledPackageRoots, INSTALLED_PLUGIN_DIGEST_PATTERN } from "./installed-package-roots.js";
1
2
  /**
2
3
  * @file Resolves a run's pinned Agent Plugin refs (`pluginRefIds` — `run-start-context.ts`'s
3
4
  * decoded `contextRef` field, itself sourced from the composer's `pluginRefId` chips,
@@ -65,13 +66,11 @@ import { createCapabilityProjectionModule } from "./capability-projection.js";
65
66
  function buildModule(ports) {
66
67
  const persistentState = createPersistentStateModule(ports);
67
68
  const { assertContainedOnDisk } = createPackagePathsModule(ports);
68
- const readdir = ports.filesystem.readdir.bind(ports.filesystem);
69
69
  const stat = ports.filesystem.stat.bind(ports.filesystem);
70
70
  const { resolveAgentPluginActivation } = createActivationModule(ports);
71
71
  const { preferBundledAgentPluginDigests, readBundledAgentPluginDigests } = createBundledDigestsModule(ports);
72
72
  const { indexInstalledRoot } = createInstallModule(ports);
73
73
  const { readInstalledSkillMarkdown } = createCapabilityProjectionModule(ports);
74
- const SHA256_DIGEST_DIRNAME_PATTERN = /^[a-f0-9]{64}$/;
75
74
  /** Reads the delivery mode for this process. An unset or unrecognised value is `inject` — an A/B
76
75
  * affordance must never be able to change production behaviour by typo. */
77
76
  function resolveAgentPluginDeliveryMode() { return ports.deliveryMode; }
@@ -147,40 +146,15 @@ function buildModule(ports) {
147
146
  * re-walking `packages/sha256/*` a second, less-validated way keeps the digest-directory-name check
148
147
  * and the per-digest failure isolation in exactly one place. */
149
148
  async function listInstalledPlugins(workspaceRoot) {
150
- let folders;
151
- try {
152
- folders = await readdir(workspaceRoot, { withFileTypes: true });
153
- }
154
- catch (error) {
155
- if (isEnoent(error))
156
- return [];
157
- throw error;
158
- }
159
149
  const installed = [];
160
- for (const folder of folders) {
161
- if (!folder.isDirectory() || !/^[a-z0-9]+(?:[-.][a-z0-9]+)*$/.test(folder.name) || folder.name.length > 64)
162
- continue;
163
- const packagesDir = path.join(workspaceRoot, folder.name, "package", "sha256");
164
- let entries;
150
+ for (const root of await listInstalledPackageRoots({ ports, workspaceRoot })) {
165
151
  try {
166
- await persistentState.assertOwnedPath({ workspaceRoot, entryPath: path.relative(workspaceRoot, packagesDir) });
167
- entries = await readdir(packagesDir, { withFileTypes: true });
152
+ const plugin = await indexInstalledRoot(root.packageRoot, root.archiveDigest);
153
+ if (plugin.pluginId === root.localId)
154
+ installed.push(plugin);
168
155
  }
169
156
  catch {
170
- continue;
171
- }
172
- for (const entry of entries) {
173
- if (!entry.isDirectory() || !SHA256_DIGEST_DIRNAME_PATTERN.test(entry.name))
174
- continue;
175
- try {
176
- const packageRoot = await assertContainedOnDisk(workspaceRoot, path.relative(workspaceRoot, path.join(packagesDir, entry.name)));
177
- const plugin = await indexInstalledRoot(packageRoot, entry.name);
178
- if (plugin.pluginId === folder.name)
179
- installed.push(plugin);
180
- }
181
- catch {
182
- // One corrupt package never hides another plugin. Memory-only folders are not installs.
183
- }
157
+ // One corrupt package never hides another plugin. Memory-only folders are not installs.
184
158
  }
185
159
  }
186
160
  return installed;
@@ -208,7 +182,7 @@ function buildModule(ports) {
208
182
  * @complexity One `stat`.
209
183
  */
210
184
  async function isInstalledDigestPresent(packagesDir, archiveDigest) {
211
- if (!SHA256_DIGEST_DIRNAME_PATTERN.test(archiveDigest))
185
+ if (!INSTALLED_PLUGIN_DIGEST_PATTERN.test(archiveDigest))
212
186
  return false;
213
187
  try {
214
188
  return (await stat(path.join(packagesDir, archiveDigest))).isDirectory();
@@ -217,9 +191,6 @@ function buildModule(ports) {
217
191
  return false;
218
192
  }
219
193
  }
220
- function isEnoent(error) {
221
- return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
222
- }
223
194
  /**
224
195
  * Resolves one `pluginRefId`, applying the zero/one/many-match rules described in this module's
225
196
  * own header.
@@ -67,6 +67,8 @@ export interface AgentPluginSearchCandidate {
67
67
  readonly summary?: string | undefined;
68
68
  readonly description?: (string) | undefined;
69
69
  readonly keywords: readonly string[];
70
+ readonly id?: string | undefined;
71
+ readonly provides?: readonly string[] | undefined;
70
72
  /** Whether this workspace currently has the plugin enabled — reflects `activation.ts`'s
71
73
  * absent-means-active default, same as everywhere else this codebase reads activation state. */
72
74
  readonly enabled: boolean;
@@ -18,8 +18,8 @@ function buildModule(ports) {
18
18
  * plain, independently-readable list rather than interleaved with the scoring loop itself. */
19
19
  function fieldHaystacks(candidate) {
20
20
  return [
21
- { text: candidate.pluginId.toLowerCase(), weight: ID_FIELD_WEIGHT },
22
- { text: candidate.keywords.join(" ").toLowerCase(), weight: KEYWORD_FIELD_WEIGHT },
21
+ { text: `${candidate.pluginId} ${candidate.id ?? ""}`.toLowerCase(), weight: ID_FIELD_WEIGHT },
22
+ { text: [...candidate.keywords, ...(candidate.provides ?? [])].join(" ").toLowerCase(), weight: KEYWORD_FIELD_WEIGHT },
23
23
  { text: (candidate.description ?? "").toLowerCase(), weight: DESCRIPTION_FIELD_WEIGHT },
24
24
  {
25
25
  text: candidate.skills.map((skill) => `${skill.name} ${skill.summary}`).join(" ").toLowerCase(),
@@ -6,6 +6,8 @@ export interface InstalledAgentPluginSkill {
6
6
  readonly skillPath: string;
7
7
  }
8
8
  export interface InstalledAgentPlugin {
9
+ readonly id?: string | undefined;
10
+ readonly provides?: readonly string[] | undefined;
9
11
  readonly pluginId: string;
10
12
  readonly version?: (string) | undefined;
11
13
  /** the host extension namespace in `plugin.json`: `displayName` — the human-facing title when the id alone reads
@@ -20,6 +20,7 @@ export interface PluginManifestAuthor {
20
20
  readonly email?: string;
21
21
  readonly url?: string;
22
22
  }
23
+ export type { PluginIdentityExtension } from "@jini-ai/core/plugin-directory";
23
24
  export interface PluginManifest {
24
25
  readonly $schema?: string;
25
26
  /** 1-64 chars, lowercase alphanumeric/dots/hyphens, no leading/trailing/doubled separators. */
@@ -60,6 +61,8 @@ export declare function isMcpManifest(value: unknown): value is McpManifest;
60
61
  /** Object-argument facade for the legacy structural validator. */
61
62
  export declare function validatePluginManifest(required: {
62
63
  readonly value: unknown;
64
+ }, optional?: {
65
+ readonly identityNamespace?: string;
63
66
  }): boolean;
64
67
  /** Object-argument facade for the legacy structural validator. */
65
68
  export declare function validateMcpManifest(required: {
package/dist/manifest.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { validatePluginIdentity } from "@jini-ai/core/plugin-directory";
1
2
  /**
2
3
  * Types for the Agent Plugins 1.0.0 open standard (published by a Technical Steering Committee
3
4
  * with maintainers from Amazon, Cursor, Google, Microsoft, OpenAI, and Vercel). A plugin is a
@@ -28,8 +29,8 @@ export function isMcpManifest(value) {
28
29
  return typeof servers === 'object' && servers !== null && !Array.isArray(servers);
29
30
  }
30
31
  /** Object-argument facade for the legacy structural validator. */
31
- export function validatePluginManifest(required) {
32
- return isPluginManifest(required.value);
32
+ export function validatePluginManifest(required, optional = {}) {
33
+ return isPluginManifest(required.value) && validatePluginIdentity({ manifest: required.value, namespace: optional.identityNamespace ?? "tovu" }).errors.length === 0;
33
34
  }
34
35
  /** Object-argument facade for the legacy structural validator. */
35
36
  export function validateMcpManifest(required) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jini-ai/agent-plugins",
3
- "version": "0.4.2",
4
- "description": "Support for Agent Plugins, the open, vendor-neutral spec (v1.0.0, published 2026-08-06, Technical Steering Committee with maintainers from Amazon, Cursor, Google, Microsoft, OpenAI, and Vercel) for bundling Agent Skills and MCP servers into one portable directory: PluginManifest/McpManifest types + structural validators on the package root, plus the bundled plugins themselves, each on a subpath named for itself (./ui-ux-design). Extracted 2026-08-18 from @jini-ai/plugins, which now only reserves the unrelated, unimplemented ./host format.",
3
+ "version": "0.4.4",
4
+ "description": "Support for Agent Plugins, the open, vendor-neutral spec (v1.0.0, published 2026-08-06; v1.1.0 and later 1.x manifests are also accepted; Technical Steering Committee with maintainers from Amazon, Cursor, Google, Microsoft, OpenAI, and Vercel) for bundling Agent Skills and MCP servers into one portable directory: PluginManifest/McpManifest types + structural validators on the package root, plus the bundled plugins themselves, each on a subpath named for itself (./ui-ux-design). Extracted 2026-08-18 from @jini-ai/plugins, which now only reserves the unrelated, unimplemented ./host format.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -89,21 +89,18 @@
89
89
  "devDependencies": {
90
90
  "@vitest/coverage-v8": "^2.1.9",
91
91
  "yazl": "^3.3.1",
92
- "@jini-ai/core": "^0.4.0",
92
+ "@jini-ai/core": "^0.4.2",
93
93
  "@jini-ai/platform": "^0.5.0"
94
94
  },
95
95
  "peerDependencies": {
96
96
  "yauzl": "^3.4.0",
97
- "@jini-ai/core": "^0.4.0",
98
- "@jini-ai/platform": "^0.5.0"
97
+ "@jini-ai/core": "^0.4.3",
98
+ "@jini-ai/platform": "^0.5.3"
99
99
  },
100
100
  "peerDependenciesMeta": {
101
101
  "yauzl": {
102
102
  "optional": true
103
103
  },
104
- "@jini-ai/core": {
105
- "optional": true
106
- },
107
104
  "@jini-ai/platform": {
108
105
  "optional": true
109
106
  }
@@ -1,5 +1,19 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "ui-ux-design",
4
- "description": "UI/UX design, interface-design-system, accessibility, and shadcn/ui component skills bundled as one portable Agent Plugin."
4
+ "description": "UI/UX design, interface-design-system, accessibility, and shadcn/ui component skills bundled as one portable Agent Plugin.",
5
+ "extensions": {
6
+ "tovu": {
7
+ "id": "jini/ui-ux-design",
8
+ "provides": [
9
+ "ui-design"
10
+ ]
11
+ }
12
+ },
13
+ "keywords": [
14
+ "design",
15
+ "ui",
16
+ "ux",
17
+ "ui-design"
18
+ ]
5
19
  }