ソースを参照

feat(marketplace): add remote registry lifecycle

Alvin Unreal 5 日 前
コミット
fd6e5e744c

+ 19 - 12
README.md

@@ -48,9 +48,9 @@ The main idea is simple: instead of forcing one model to do everything, the plug
   models at runtime with `/preset`.
 - **[Code intelligence tools](docs/tools.md)** - LSP tools, AST-aware search
   across 25 languages, and built-in MCPs for docs and GitHub code
-- **[Local marketplace](docs/marketplace.md)** - install and activate local
-  offline agent/profile packages from the CLI or the in-session
-  `marketplace` tool; changes apply after reload
+- **[Marketplace](docs/marketplace.md)** - install registry packages or import
+  local agent/profile packages explicitly; startup and local reads stay offline
+  and changes apply after reload
   search.
 - **[Fully customizable](docs/configuration.md)** - custom agents, prompt
   overrides, per-agent skill/MCP permissions, and
@@ -102,10 +102,11 @@ have Bun installed:
 npx oh-my-opencode-slim@latest install
 ```
 
-### Local Marketplace Packages
+### Marketplace Packages
 
-The marketplace lifecycle is local and offline. Package manifests are
-data-only, exact-version locked, and stored under the XDG data directory.
+Startup and local marketplace reads are offline. Explicit registry install and
+update operations use bounded HTTPS requests to the fixed beta registry.
+Package manifests are data-only, exact-version locked, and stored under the XDG data directory.
 Installation and preset activation are separate: install a package, then enable
 it in the active preset. Activated agents and profiles apply after the next
 OpenCode session/reload; the live registry is never hot-swapped.
@@ -116,10 +117,13 @@ append/replace composition semantics. Executable fields,
 package-to-package dependencies, and arbitrary file maps are rejected.
 
 ```bash
-bunx oh-my-opencode-slim marketplace install ./package.json
+bunx oh-my-opencode-slim marketplace install community/example
+bunx oh-my-opencode-slim marketplace install community/example@1.2.3
+bunx oh-my-opencode-slim marketplace import ./package.json
 bunx oh-my-opencode-slim marketplace list
 bunx oh-my-opencode-slim marketplace verify [author/name]
-bunx oh-my-opencode-slim marketplace update ./package-v2.json
+bunx oh-my-opencode-slim marketplace import ./package-v2.json --update
+bunx oh-my-opencode-slim marketplace update community/example
 bunx oh-my-opencode-slim marketplace enable author/name
 bunx oh-my-opencode-slim marketplace profile librarian author/profile
 bunx oh-my-opencode-slim marketplace disable author/name
@@ -127,16 +131,19 @@ bunx oh-my-opencode-slim marketplace remove author/name
 bunx oh-my-opencode-slim marketplace status
 ```
 
-Use `update` explicitly to select a different exact version. `enable` activates
+Use `import` explicitly for local author files; `import --update` requires an
+existing package and a strictly newer version. Unversioned registry install
+selects the highest compatible version, while `ID@version` selects exactly
+that version. Registry update requires an installed package and is strictly
+monotonic. `enable` activates
 an installed `agent` package in the active preset as a separately named
 role-derived agent. `profile` selects at most one installed `profile` package
 per supported specialist role; `--clear` writes a tombstone. Required skills
 and MCPs are preflighted against built-in capabilities and on-disk host
 configuration; missing required dependencies disable that package for the
 session. Optional requirements stay unavailable and are never auto-installed.
-`import` is an alias for `install` and records the canonical absolute local
-source path in the lockfile. Startup reads only the local store and never
-contacts a registry. The orchestrator can perform the same local lifecycle
+Startup reads only the local store and never contacts a registry. The
+orchestrator can perform the same lifecycle
 with the in-session `marketplace` tool; see
 [Local Marketplace](docs/marketplace.md).
 

+ 31 - 15
docs/marketplace.md

@@ -1,7 +1,8 @@
-# Local Marketplace
+# Marketplace
 
-Install, inspect, and activate local offline marketplace packages. There is
-no website, network registry, or remote package resolution in this release.
+Install, inspect, and activate marketplace packages. Startup and every local
+read are offline. Only explicit registry `install` and `update` operations
+make bounded HTTPS requests to the beta registry.
 
 Package manifests are data-only, exact-version locked, and stored under the
 XDG data directory. Installation and preset activation are separate: install
@@ -12,12 +13,14 @@ registry is never hot-swapped.
 ## CLI
 
 ```bash
-bunx oh-my-opencode-slim marketplace install ./package.json
+bunx oh-my-opencode-slim marketplace install community/example
+bunx oh-my-opencode-slim marketplace install community/example@1.2.3
+bunx oh-my-opencode-slim marketplace update community/example
 bunx oh-my-opencode-slim marketplace import ./package.json
+bunx oh-my-opencode-slim marketplace import ./package-v2.json --update
 bunx oh-my-opencode-slim marketplace list
 bunx oh-my-opencode-slim marketplace show author/name
 bunx oh-my-opencode-slim marketplace verify [author/name]
-bunx oh-my-opencode-slim marketplace update ./package-v2.json
 bunx oh-my-opencode-slim marketplace enable author/name
 bunx oh-my-opencode-slim marketplace profile librarian author/profile
 bunx oh-my-opencode-slim marketplace profile oracle --clear
@@ -26,9 +29,12 @@ bunx oh-my-opencode-slim marketplace remove author/name
 bunx oh-my-opencode-slim marketplace status [--json]
 ```
 
-`import` is an alias for `install` and records the canonical absolute local
-source path in the lockfile. Use `update` to select a different exact
-version. `enable` activates an installed `agent` package in the active
+`import` is the explicit local author workflow and records the canonical
+absolute local source path in the lockfile. Add `--update` to import a strictly
+newer version into an existing package. Registry `install` accepts an ID or an
+exact `ID@version`; an unversioned ID selects the highest compatible version.
+Registry `update` requires an installed package and selects only a strictly
+newer compatible version. `enable` activates an installed `agent` package in the active
 preset as a separately named role-derived agent. `profile` selects at most
 one installed `profile` package per supported specialist role; `--clear`
 writes a tombstone.
@@ -43,15 +49,15 @@ compared.
 
 ## In-session tool
 
-The orchestrator can use the `marketplace` tool for the same local
-lifecycle: install, import, list, show, verify, update, enable, disable,
-profile, remove, and status. Do not shell out to the CLI for these actions
-when the tool is available.
+The orchestrator can use the `marketplace` tool for the same lifecycle. Its
+`install`/`update` actions use the fixed registry, while `import` is the only
+local path action. Do not shell out to the CLI when the tool is available.
 
 Disable it with `disabled_tools: ["marketplace"]`. Specialists cannot
 invoke it.
 
-Read-only actions (list, show, verify, status) do not change activation.
+Read-only actions (list, show, verify, status) do not change activation or
+contact the registry.
 Mutating actions write the local store and plugin config only and report
 `reload_required` only when disk activation differs from this session.
 Inactive or idempotent mutations do not include a reload note. The CLI
@@ -71,9 +77,19 @@ read (for example EACCES).
 
 ## Limits
 
-- Local `package.json` files only. No remote URLs or registry IDs.
+- The beta registry is fixed at `https://registry.ohmyopencodeslim.com/v1/`;
+  configurable registries and redirects are not supported.
 - Required skills and MCPs are preflighted against built-in capabilities
   and on-disk host configuration. Missing required dependencies disable
   that package for the session. Optional requirements stay unavailable
   and are never auto-installed.
-- Startup reads only the local store and never contacts a registry.
+- Startup and local `list`, `show`, `verify`, `status`, activation, and removal
+  read only the local store and never contact a registry.
+
+## Registry contract
+
+Registry CI and static site tooling can import the narrow
+`oh-my-opencode-slim/marketplace-contract` package subpath. It provides the
+schema-v1 index, deterministic artifact paths, manifest-summary projection,
+selector resolution, and the same canonical bundle SHA-256 digest used by the
+plugin store. The public contract does not add root-package exports.

+ 7 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "oh-my-opencode-slim",
-  "version": "2.2.20",
+  "version": "3.0.0-beta.1",
   "packageManager": "bun@1.3.14",
   "description": "Lightweight agent orchestration plugin for OpenCode - a slimmed-down fork of oh-my-opencode",
   "main": "dist/index.js",
@@ -16,6 +16,10 @@
     "./tui": {
       "import": "./dist/tui2.js",
       "types": "./dist/v2/tui.d.ts"
+    },
+    "./marketplace-contract": {
+      "import": "./dist/marketplace-contract/index.js",
+      "types": "./dist/marketplace-contract/index.d.ts"
     }
   },
   "bin": {
@@ -57,10 +61,11 @@
   "scripts": {
     "clean:dist": "bun -e \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true })\"",
     "build:plugin": "bun build src/index.ts src/tui.ts --outdir dist --target node --format esm --external @opencode-ai/plugin --external @opencode-ai/plugin/tui --external @opencode-ai/sdk --external @opencode-ai/sdk/v2 --external @opentui/core --external @opentui/solid --external solid-js --external jsdom --external zod",
+    "build:marketplace-contract": "bun build src/marketplace-contract/index.ts --outfile dist/marketplace-contract/index.js --target node --format esm --external zod",
     "build:v2": "bun build src/index.ts --outfile dist/server/index.js --target node --format esm --external jsdom",
     "build:tui": "bun build src/v2/tui.ts --outfile dist/tui2.js --target node --format esm --external @opencode-ai/plugin --external @opencode-ai/plugin/tui --external @opencode-ai/sdk --external @opencode-ai/sdk/v2 --external @opentui/core --external @opentui/solid --external solid-js --external jsdom",
     "build:cli": "bun build src/cli/index.ts --outdir dist/cli --target node --format esm --external @opencode-ai/plugin --external @opencode-ai/plugin/tui --external @opencode-ai/sdk --external @opencode-ai/sdk/v2 --external jsdom --external zod",
-    "build": "bun run clean:dist && bun run build:plugin && bun run build:v2 && bun run build:tui && bun run build:cli && tsc --emitDeclarationOnly && bun run generate-schema",
+    "build": "bun run clean:dist && bun run build:plugin && bun run build:v2 && bun run build:tui && bun run build:cli && bun run build:marketplace-contract && tsc --emitDeclarationOnly && bun run generate-schema",
     "prepare": "bun run build",
     "contributors:add": "all-contributors add",
     "contributors:check": "all-contributors check",

+ 12 - 0
scripts/verify-release-artifact.ts

@@ -32,6 +32,8 @@ const packagedRequiredFiles = [
   'dist/tui.js',
   'dist/tui.d.ts',
   'dist/cli/index.js',
+  'dist/marketplace-contract/index.js',
+  'dist/marketplace-contract/index.d.ts',
   'oh-my-opencode-slim.schema.json',
   'src/companion/companion-manifest.json',
   'src/skills/simplify/SKILL.md',
@@ -271,6 +273,16 @@ function verifyFreshInstall(tarballPath: string) {
     run('node', ['--input-type=module', '--eval', serverSmokeScript], {
       cwd: installDir,
     });
+
+    const contractSmokeScript = [
+      "import { registryArtifactPath } from 'oh-my-opencode-slim/marketplace-contract';",
+      "if (registryArtifactPath('community/example', '1.0.0') !== 'artifacts/community/example/1.0.0.json') throw new Error('marketplace contract export failed');",
+      "console.log('marketplace contract package loads');",
+    ].join('\n');
+    console.log('Importing marketplace contract subpath...');
+    run('node', ['--input-type=module', '--eval', contractSmokeScript], {
+      cwd: installDir,
+    });
   } finally {
     rmSync(tempRoot, { recursive: true, force: true });
   }

+ 1 - 1
src/agents/orchestrator.test.ts

@@ -48,7 +48,7 @@ describe('orchestrator prompt', () => {
     expect(prompt).toContain('Use the `marketplace` tool');
     expect(prompt).toContain('list, show, verify, and status are read-only');
     expect(prompt).toContain(
-      'report reload_required only when disk activation differs',
+      'Use install/update with a canonical package ID for the fixed HTTPS registry',
     );
   });
 

+ 2 - 2
src/agents/orchestrator.ts

@@ -108,7 +108,7 @@ export interface RoutingEntry {
  * @param waitForUserEnabled - Whether explicit text-only HITL waiting is available
  * @param wakeSchedulerEnabled - Whether the orchestrator wake scheduler can resume the session after idle
  * @param hostFlavor - Host flavor marker ('v2' on OpenCode v2 hosts); selects the native delegation vocabulary
- * @param marketplaceEnabled - Whether the local marketplace tool is available
+ * @param marketplaceEnabled - Whether the marketplace tool is available
  * @returns The complete orchestrator prompt string
  */
 export function buildOrchestratorPrompt(
@@ -172,7 +172,7 @@ export function buildOrchestratorPrompt(
     : '- When work must pause while the user completes an external manual operation, first give the user concrete manual steps, then use the `question` tool as the blocking boundary and ask them to respond when finished. `wait_for_user` is disabled, so do not reference or call it.';
 
   const marketplaceInstruction = marketplaceEnabled
-    ? '- Use the `marketplace` tool for local offline package lifecycle and status. list, show, verify, and status are read-only. install, import, update, enable, disable, profile, and remove write store/config only and never hot-swap the live registry; they report reload_required only when disk activation differs from this session. Do not shell out to the CLI for these actions.'
+    ? '- Use the `marketplace` tool for package lifecycle and status. list, show, verify, and status are read-only and offline. Use install/update with a canonical package ID for the fixed HTTPS registry; use import with a local path (and update=true only for a strictly newer existing package). Do not infer path versus ID, do not shell out to the CLI, and remember mutations never hot-swap the live registry.'
     : '';
 
   return `<Role>

+ 3 - 3
src/cli/index.ts

@@ -76,9 +76,9 @@ oh-my-opencode-slim installer
 Usage:
   bunx oh-my-opencode-slim install [OPTIONS]
   bunx oh-my-opencode-slim doctor [OPTIONS]
-  bunx oh-my-opencode-slim marketplace install <package.json>
-  bunx oh-my-opencode-slim marketplace import <package.json> (alias for install)
-  bunx oh-my-opencode-slim marketplace update <package.json>
+  bunx oh-my-opencode-slim marketplace install <publisher/package[@version]>
+  bunx oh-my-opencode-slim marketplace import <package.json> [--update]
+  bunx oh-my-opencode-slim marketplace update <publisher/package>
   bunx oh-my-opencode-slim marketplace list
   bunx oh-my-opencode-slim marketplace show <package-id> [--json]
   bunx oh-my-opencode-slim marketplace verify [package-id] [--json]

+ 24 - 2
src/cli/marketplace.test.ts

@@ -2,14 +2,33 @@ import { describe, expect, test } from 'bun:test';
 import { parseMarketplaceArgs } from './marketplace';
 
 describe('marketplace CLI parsing', () => {
-  test('parses lifecycle commands and the import alias', () => {
+  test('keeps local import distinct from registry install/update', () => {
     expect(parseMarketplaceArgs(['import', './package.json'])).toEqual({
-      command: 'install',
+      command: 'import',
       value: './package.json',
       force: false,
       json: false,
       clear: false,
     });
+    expect(
+      parseMarketplaceArgs(['import', './package-v2.json', '--update']),
+    ).toEqual({
+      command: 'import',
+      value: './package-v2.json',
+      force: false,
+      json: false,
+      clear: false,
+      update: true,
+    });
+    expect(
+      parseMarketplaceArgs(['install', 'community/example@1.2.3']),
+    ).toEqual({
+      command: 'install',
+      value: 'community/example@1.2.3',
+      force: false,
+      json: false,
+      clear: false,
+    });
   });
 
   test('requires package input for install and update', () => {
@@ -22,6 +41,9 @@ describe('marketplace CLI parsing', () => {
       json: false,
       clear: false,
     });
+    expect(parseMarketplaceArgs(['update', 'community/example']).command).toBe(
+      'update',
+    );
     expect(() =>
       parseMarketplaceArgs(['remove', 'community/example', '--json']),
     ).toThrow();

+ 44 - 10
src/cli/marketplace.ts

@@ -15,6 +15,7 @@ import {
 
 export type MarketplaceCommandName =
   | 'install'
+  | 'import'
   | 'list'
   | 'show'
   | 'verify'
@@ -32,6 +33,7 @@ export interface MarketplaceArgs {
   force: boolean;
   json: boolean;
   clear: boolean;
+  update?: boolean;
 }
 
 function commandSet(): readonly string[] {
@@ -57,13 +59,11 @@ export function parseMarketplaceArgs(args: string[]): MarketplaceArgs {
       'Usage: marketplace install|import|list|show|verify|update|remove|enable|disable|profile|status [value] [options]',
     );
   }
-  const command: MarketplaceCommandName =
-    rawCommand === 'import'
-      ? 'install'
-      : (rawCommand as MarketplaceCommandName);
+  const command = rawCommand as MarketplaceCommandName;
   const force = args.includes('--force');
   const json = args.includes('--json');
   const clear = args.includes('--clear');
+  const update = args.includes('--update');
   const options = args.filter((arg) => arg.startsWith('--'));
   const allowedOptions = new Set(
     command === 'remove'
@@ -72,7 +72,9 @@ export function parseMarketplaceArgs(args: string[]): MarketplaceArgs {
         ? ['--json']
         : command === 'profile'
           ? ['--clear']
-          : [],
+          : command === 'import'
+            ? ['--update']
+            : [],
   );
   for (const option of options) {
     if (!allowedOptions.has(option)) {
@@ -91,7 +93,13 @@ export function parseMarketplaceArgs(args: string[]): MarketplaceArgs {
       if (positional.length > 1) {
         throw new Error('marketplace profile --clear accepts only a role');
       }
-      return { command, role: positional[0], force, json, clear };
+      return {
+        command,
+        role: positional[0],
+        force,
+        json,
+        clear,
+      };
     }
     if (!positional[1] || positional.length > 2) {
       throw new Error('marketplace profile requires a role and package ID');
@@ -107,6 +115,7 @@ export function parseMarketplaceArgs(args: string[]): MarketplaceArgs {
   }
   const needsValue = [
     'install',
+    'import',
     'show',
     'update',
     'remove',
@@ -122,12 +131,25 @@ export function parseMarketplaceArgs(args: string[]): MarketplaceArgs {
   if (needsValue && !positional[0]) {
     throw new Error(`marketplace ${rawCommand} requires a value`);
   }
-  if ((command === 'install' || command === 'update') && force) {
+  if (
+    (command === 'install' || command === 'update' || command === 'import') &&
+    force
+  ) {
     throw new Error(
       `Option --force is not valid for marketplace ${rawCommand}`,
     );
   }
-  return { command, value: positional[0], force, json, clear };
+  if (command !== 'import' && update) {
+    throw new Error('Option --update is only valid for marketplace import');
+  }
+  return {
+    command,
+    value: positional[0],
+    force,
+    json,
+    clear,
+    ...(command === 'import' && update ? { update: true } : {}),
+  };
 }
 
 export async function marketplaceCommand(
@@ -140,7 +162,7 @@ export async function marketplaceCommand(
     const projectDir = options.projectDir ?? process.cwd();
     switch (parsed.command) {
       case 'install': {
-        const pkg = service.installFile(parsed.value as string);
+        const pkg = await service.installRemote(parsed.value as string);
         console.log(
           mutationReloadNotice(
             `Installed ${pkg.manifest.id}@${pkg.manifest.version}`,
@@ -149,8 +171,20 @@ export async function marketplaceCommand(
         );
         return 0;
       }
+      case 'import': {
+        const pkg = parsed.update
+          ? service.importFileUpdate(parsed.value as string)
+          : service.importFile(parsed.value as string);
+        console.log(
+          mutationReloadNotice(
+            `${parsed.update ? 'Updated' : 'Imported'} ${pkg.manifest.id}@${pkg.manifest.version}`,
+            'unknown',
+          ),
+        );
+        return 0;
+      }
       case 'update': {
-        const pkg = service.updateFile(parsed.value as string);
+        const pkg = await service.updateRemote(parsed.value as string);
         console.log(
           mutationReloadNotice(
             `Updated ${pkg.manifest.id}@${pkg.manifest.version}`,

+ 1 - 1
src/hooks/__snapshots__/cache-payload.snapshot.test.ts.snap

@@ -206,7 +206,7 @@ After spawning all independent background tasks and any remaining non-overlappin
 - Do make reasonable assumptions for minor details and state them briefly
 - When user input is required before work can continue and the user can answer immediately—including clarification, permission, a choice, or pasted command output—use the \`question\` tool. Enable custom input, request a concise pasted response or command output, and provide a small bounded set of options whenever the tool schema requires options.
 - When work must pause while the user completes an external manual operation, first give the user concrete manual steps, then call \`wait_for_user\` as your final tool action and end the turn. Do not rely on ordinary text alone to mark this waiting state, and do not call more tools after \`wait_for_user\`. Background tasks are not external manual work — never use \`wait_for_user\` to await them; the system resumes automatically via the Background Job Board and orchestrator wake scheduler.
-- Use the \`marketplace\` tool for local offline package lifecycle and status. list, show, verify, and status are read-only. install, import, update, enable, disable, profile, and remove write store/config only and never hot-swap the live registry; they report reload_required only when disk activation differs from this session. Do not shell out to the CLI for these actions.
+- Use the \`marketplace\` tool for package lifecycle and status. list, show, verify, and status are read-only and offline. Use install/update with a canonical package ID for the fixed HTTPS registry; use import with a local path (and update=true only for a strictly newer existing package). Do not infer path versus ID, do not shell out to the CLI, and remember mutations never hot-swap the live registry.
 - For ordinary dialogue that does not block work, answer normally and do not use the question tool gratuitously.
 
 ## Concise Execution

+ 305 - 0
src/marketplace-contract/index.ts

@@ -0,0 +1,305 @@
+import { compare, gt, satisfies } from 'semver';
+import { z } from 'zod';
+import {
+  canonicalizeMarketplaceBundle,
+  canonicalizeMarketplaceValue,
+  compareMarketplaceCodeUnits,
+  digestMarketplaceBundle,
+} from '../marketplace/canonical';
+import {
+  MARKETPLACE_DIGEST_DOMAIN,
+  MARKETPLACE_MANIFEST_SCHEMA_VERSION,
+  MarketplaceAgentManifestSchema,
+  type MarketplaceDigest,
+  MarketplaceDigestSchema,
+  type MarketplacePackageBundle,
+  MarketplacePackageBundleSchema,
+  type MarketplacePackageId,
+  MarketplacePackageIdSchema,
+  type MarketplacePackageManifest,
+  MarketplacePackageManifestSchema,
+  MarketplaceProfileManifestSchema,
+  type MarketplaceVersion,
+  MarketplaceVersionSchema,
+} from '../marketplace/schemas';
+
+export type {
+  MarketplaceDigest,
+  MarketplacePackageBundle,
+  MarketplacePackageId,
+  MarketplacePackageManifest,
+  MarketplaceVersion,
+};
+export {
+  canonicalizeMarketplaceBundle,
+  canonicalizeMarketplaceValue,
+  compareMarketplaceCodeUnits,
+  digestMarketplaceBundle,
+  MARKETPLACE_DIGEST_DOMAIN,
+  MARKETPLACE_MANIFEST_SCHEMA_VERSION,
+  MarketplaceAgentManifestSchema,
+  MarketplaceDigestSchema,
+  MarketplacePackageBundleSchema,
+  MarketplacePackageIdSchema,
+  MarketplacePackageManifestSchema,
+  MarketplaceProfileManifestSchema,
+  MarketplaceVersionSchema,
+};
+
+export const MARKETPLACE_REGISTRY_SCHEMA_VERSION = 1 as const;
+export const DEFAULT_MARKETPLACE_REGISTRY_URL =
+  'https://registry.ohmyopencodeslim.com/v1/' as const;
+
+const MarketplaceAgentSummarySchema = MarketplaceAgentManifestSchema.omit({
+  instructions: true,
+});
+const MarketplaceProfileSummarySchema = MarketplaceProfileManifestSchema.omit({
+  instructions: true,
+});
+
+/** Public catalog metadata; package instructions never enter the index. */
+export const MarketplaceManifestSummarySchema = z.discriminatedUnion('kind', [
+  MarketplaceAgentSummarySchema,
+  MarketplaceProfileSummarySchema,
+]);
+
+export const MarketplaceRegistryEntrySchema = z
+  .object({
+    id: MarketplacePackageIdSchema,
+    version: MarketplaceVersionSchema,
+    artifactPath: z
+      .string()
+      .regex(
+        /^artifacts\/[a-z0-9][a-z0-9._-]{0,63}\/[a-z0-9][a-z0-9._-]{0,63}\/[0-9A-Za-z.+-]+\.json$/,
+        'Expected a deterministic registry artifact path',
+      ),
+    digest: z
+      .object({
+        algorithm: z.literal('sha256'),
+        domain: z.literal(MARKETPLACE_DIGEST_DOMAIN),
+        value: z.string().regex(/^[0-9a-f]{64}$/),
+      })
+      .strict(),
+    summary: MarketplaceManifestSummarySchema,
+  })
+  .strict();
+
+export const MarketplaceRegistryIndexSchema = z
+  .object({
+    schemaVersion: z.literal(MARKETPLACE_REGISTRY_SCHEMA_VERSION),
+    entries: z.array(MarketplaceRegistryEntrySchema).max(100_000),
+  })
+  .strict()
+  .superRefine((index, ctx) => {
+    const seen = new Set<string>();
+    for (let position = 0; position < index.entries.length; position += 1) {
+      const entry = index.entries[position];
+      const key = `${entry.id}@${entry.version}`;
+      if (seen.has(key)) {
+        ctx.addIssue({
+          code: 'custom',
+          path: ['entries', position],
+          message: `Duplicate registry entry ${key}`,
+        });
+      }
+      seen.add(key);
+      if (
+        entry.artifactPath !== registryArtifactPath(entry.id, entry.version)
+      ) {
+        ctx.addIssue({
+          code: 'custom',
+          path: ['entries', position, 'artifactPath'],
+          message: `Artifact path must be ${registryArtifactPath(entry.id, entry.version)}`,
+        });
+      }
+      if (
+        entry.summary.id !== entry.id ||
+        entry.summary.version !== entry.version
+      ) {
+        ctx.addIssue({
+          code: 'custom',
+          path: ['entries', position, 'summary'],
+          message: 'Summary identity must match the registry entry',
+        });
+      }
+      const previous = index.entries[position - 1];
+      if (
+        previous &&
+        (compareMarketplaceCodeUnits(previous.id, entry.id) > 0 ||
+          (previous.id === entry.id &&
+            (compare(previous.version, entry.version) > 0 ||
+              (compare(previous.version, entry.version) === 0 &&
+                compareMarketplaceCodeUnits(previous.version, entry.version) >=
+                  0))))
+      ) {
+        ctx.addIssue({
+          code: 'custom',
+          path: ['entries', position],
+          message: 'Registry entries must be sorted by ID then version',
+        });
+      }
+    }
+  });
+
+export type MarketplaceManifestSummary = z.infer<
+  typeof MarketplaceManifestSummarySchema
+>;
+export type MarketplaceRegistryEntry = z.infer<
+  typeof MarketplaceRegistryEntrySchema
+>;
+export type MarketplaceRegistryIndex = z.infer<
+  typeof MarketplaceRegistryIndexSchema
+>;
+
+export function canonicalizeMarketplaceRegistryIndex(
+  index: MarketplaceRegistryIndex,
+): string {
+  return canonicalizeMarketplaceValue(index);
+}
+
+export function registryArtifactPath(id: string, version: string): string {
+  const [publisher, name] = id.split('/');
+  return `artifacts/${publisher}/${name}/${version}.json`;
+}
+
+export function projectMarketplaceManifestSummary(
+  manifest: MarketplacePackageManifest,
+): MarketplaceManifestSummary {
+  const parsed = MarketplacePackageBundleSchema.shape.manifest.parse(manifest);
+  const { instructions: _instructions, ...summary } = parsed;
+  return summary as MarketplaceManifestSummary;
+}
+
+export function createMarketplaceRegistryEntry(
+  bundle: MarketplacePackageBundle,
+): MarketplaceRegistryEntry {
+  const parsed = MarketplacePackageBundleSchema.parse(bundle);
+  return {
+    id: parsed.manifest.id,
+    version: parsed.manifest.version,
+    artifactPath: registryArtifactPath(
+      parsed.manifest.id,
+      parsed.manifest.version,
+    ),
+    digest: {
+      algorithm: 'sha256',
+      domain: MARKETPLACE_DIGEST_DOMAIN,
+      value: digestMarketplaceBundle(parsed),
+    },
+    summary: projectMarketplaceManifestSummary(parsed.manifest),
+  };
+}
+
+export function createMarketplaceRegistryIndex(
+  entries: readonly MarketplaceRegistryEntry[],
+): MarketplaceRegistryIndex {
+  const sorted = [...entries].sort(
+    (left, right) =>
+      compareMarketplaceCodeUnits(left.id, right.id) ||
+      compare(left.version, right.version) ||
+      compareMarketplaceCodeUnits(left.version, right.version),
+  );
+  return parseMarketplaceRegistryIndex({ schemaVersion: 1, entries: sorted });
+}
+
+export function parseMarketplaceRegistryIndex(
+  value: unknown,
+): MarketplaceRegistryIndex {
+  const result = MarketplaceRegistryIndexSchema.safeParse(value);
+  if (!result.success) {
+    throw new Error(
+      `Invalid marketplace registry index: ${result.error.message}`,
+    );
+  }
+  return result.data;
+}
+
+export function validateMarketplaceRegistryEntry(
+  entry: MarketplaceRegistryEntry,
+  bundle: MarketplacePackageBundle,
+): void {
+  const parsed = MarketplacePackageBundleSchema.safeParse(bundle);
+  if (!parsed.success)
+    throw new Error(`Invalid marketplace artifact: ${parsed.error.message}`);
+  const manifest = parsed.data.manifest;
+  const summary = projectMarketplaceManifestSummary(manifest);
+  if (
+    manifest.id !== entry.id ||
+    manifest.version !== entry.version ||
+    entry.artifactPath !== registryArtifactPath(entry.id, entry.version)
+  ) {
+    throw new Error(
+      'Marketplace artifact identity does not match registry entry',
+    );
+  }
+  if (
+    canonicalizeMarketplaceValue(summary) !==
+    canonicalizeMarketplaceValue(entry.summary)
+  ) {
+    throw new Error(
+      'Marketplace artifact summary does not match registry entry',
+    );
+  }
+  const digest = digestMarketplaceBundle(parsed.data);
+  if (entry.digest.value !== digest) {
+    throw new Error(
+      'Marketplace artifact digest does not match registry entry',
+    );
+  }
+}
+
+export interface MarketplaceRegistrySelector {
+  id: string;
+  version?: string;
+}
+
+export function parseMarketplaceRegistrySelector(
+  selector: string,
+): MarketplaceRegistrySelector {
+  const value = selector.trim();
+  const at = value.lastIndexOf('@');
+  const id = at === -1 ? value : value.slice(0, at);
+  const version = at === -1 ? undefined : value.slice(at + 1);
+  const idResult = MarketplacePackageIdSchema.safeParse(id.toLowerCase());
+  if (
+    !idResult.success ||
+    (version !== undefined &&
+      !MarketplaceVersionSchema.safeParse(version).success)
+  ) {
+    throw new Error(`Invalid marketplace registry selector: ${selector}`);
+  }
+  return { id: idResult.data, ...(version ? { version } : {}) };
+}
+
+export function resolveMarketplaceRegistryEntry(
+  index: MarketplaceRegistryIndex,
+  selector: MarketplaceRegistrySelector,
+  compatibility: { pluginVersion: string; roleContractVersion: string },
+  minimumVersion?: string,
+): MarketplaceRegistryEntry {
+  const candidates = index.entries.filter(
+    (entry) =>
+      entry.id === selector.id &&
+      (selector.version === undefined || entry.version === selector.version) &&
+      (minimumVersion === undefined || gt(entry.version, minimumVersion)) &&
+      satisfies(
+        compatibility.pluginVersion,
+        entry.summary.compatibility.plugin,
+      ) &&
+      satisfies(
+        compatibility.roleContractVersion,
+        entry.summary.compatibility.roleContract,
+      ),
+  );
+  const selected = [...candidates].sort(
+    (a, b) =>
+      compare(b.version, a.version) ||
+      compareMarketplaceCodeUnits(b.version, a.version),
+  )[0];
+  if (!selected) {
+    throw new Error(
+      `No compatible marketplace package found for ${selector.id}${selector.version ? `@${selector.version}` : ''}`,
+    );
+  }
+  return selected;
+}

+ 43 - 0
src/marketplace/canonical.ts

@@ -0,0 +1,43 @@
+import { createHash } from 'node:crypto';
+import type { MarketplacePackageBundle } from './schemas';
+
+export function compareMarketplaceCodeUnits(
+  left: string,
+  right: string,
+): number {
+  return left < right ? -1 : left > right ? 1 : 0;
+}
+
+/** Canonical JSON used by the package store, registry CI, and the website. */
+export function canonicalizeMarketplaceValue(value: unknown): string {
+  if (Array.isArray(value)) {
+    return `[${value.map(canonicalizeMarketplaceValue).join(',')}]`;
+  }
+  if (value !== null && typeof value === 'object') {
+    const entries = Object.entries(value as Record<string, unknown>).sort(
+      ([left], [right]) => compareMarketplaceCodeUnits(left, right),
+    );
+    return `{${entries
+      .map(
+        ([key, entry]) =>
+          `${JSON.stringify(key)}:${canonicalizeMarketplaceValue(entry)}`,
+      )
+      .join(',')}}`;
+  }
+  return JSON.stringify(value);
+}
+
+/** Returns the exact UTF-8-compatible canonical JSON text for a bundle. */
+export function canonicalizeMarketplaceBundle(
+  bundle: MarketplacePackageBundle,
+): string {
+  return canonicalizeMarketplaceValue(bundle);
+}
+
+export function digestMarketplaceBundle(
+  bundle: MarketplacePackageBundle,
+): string {
+  return createHash('sha256')
+    .update(canonicalizeMarketplaceBundle(bundle), 'utf8')
+    .digest('hex');
+}

+ 28 - 0
src/marketplace/errors.ts

@@ -70,3 +70,31 @@ export class MarketplaceActivationError extends MarketplaceError {
     this.name = 'MarketplaceActivationError';
   }
 }
+
+export class MarketplaceRegistryUnavailableError extends MarketplaceError {
+  constructor(message: string) {
+    super(message, 'registry-unavailable');
+    this.name = 'MarketplaceRegistryUnavailableError';
+  }
+}
+
+export class MarketplaceRegistryProtocolError extends MarketplaceError {
+  constructor(message: string) {
+    super(message, 'registry-protocol');
+    this.name = 'MarketplaceRegistryProtocolError';
+  }
+}
+
+export class MarketplaceRegistryNotFoundError extends MarketplaceError {
+  constructor(message: string) {
+    super(message, 'registry-not-found');
+    this.name = 'MarketplaceRegistryNotFoundError';
+  }
+}
+
+export class MarketplaceRegistryIntegrityError extends MarketplaceError {
+  constructor(message: string) {
+    super(message, 'registry-integrity');
+    this.name = 'MarketplaceRegistryIntegrityError';
+  }
+}

+ 2 - 0
src/marketplace/index.ts

@@ -1,10 +1,12 @@
 export * from './activation';
 export * from './activation-config';
+export * from './canonical';
 export * from './compatibility';
 export * from './config-references';
 export * from './errors';
 export * from './ids';
 export * from './paths';
+export * from './registry-client';
 export * from './schemas';
 export * from './service';
 export * from './status';

+ 175 - 0
src/marketplace/r1-dispatch.test.ts

@@ -0,0 +1,175 @@
+import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { marketplaceCommand } from '../cli/marketplace';
+import type { MarketplacePackageBundle } from '../marketplace-contract';
+import { createMarketplaceRegistryEntry } from '../marketplace-contract';
+import { createMarketplaceTool } from '../tools/marketplace';
+import type { MarketplaceRegistryDownload } from './registry-client';
+import { MARKETPLACE_REGISTRY_INDEX_URL } from './registry-client';
+import { MarketplaceService } from './service';
+
+function bundle(version = '1.0.0'): MarketplacePackageBundle {
+  return {
+    manifest: {
+      schemaVersion: 1,
+      id: 'community/dispatch-agent',
+      version,
+      kind: 'agent',
+      displayName: 'Dispatch agent',
+      description: 'A dispatch test package.',
+      instructions: 'Use the explorer role.',
+      author: { name: 'Community' },
+      tags: ['dispatch'],
+      license: 'MIT',
+      compatibility: { plugin: '>=3.0.0', roleContract: '^1.0.0' },
+      routing: {
+        description: 'Dispatch test routing.',
+        keywords: ['dispatch'],
+        delegation: { when: 'When dispatching.', preferredRoles: [] },
+      },
+      requirements: {
+        skills: { required: [], optional: [] },
+        mcps: { required: [], optional: [] },
+      },
+      capabilities: { tools: [], permissions: [] },
+      baseRole: 'explorer',
+      agentName: 'dispatchagent',
+      overrides: {},
+    },
+  };
+}
+
+function download(version: string): MarketplaceRegistryDownload {
+  const packageBundle = bundle(version);
+  return {
+    bundle: packageBundle,
+    entry: createMarketplaceRegistryEntry(packageBundle),
+    indexUrl: MARKETPLACE_REGISTRY_INDEX_URL,
+    packageUrl: `https://registry.ohmyopencodeslim.com/v1/artifacts/community/dispatch-agent/${version}.json`,
+  };
+}
+
+function clientFor(
+  versions: readonly string[],
+  signals: AbortSignal[] = [],
+): Pick<MarketplaceService['registryClient'], 'download'> {
+  let position = 0;
+  return {
+    download: async (_selector, _minimumVersion, signal) => {
+      if (signal) signals.push(signal);
+      const version = versions[Math.min(position++, versions.length - 1)];
+      return download(version);
+    },
+  };
+}
+
+afterEach(() => {
+  mock.restore();
+});
+
+describe('R1 explicit CLI and tool dispatch', () => {
+  test('CLI dispatches install/update to a mocked remote client', async () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-cli-dispatch-'));
+    const calls: Array<{ selector: string; minimumVersion?: string }> = [];
+    const registryClient = {
+      download: async (selector: string, minimumVersion?: string) => {
+        calls.push({ selector, minimumVersion });
+        return download(minimumVersion ? '2.0.0' : '1.0.0');
+      },
+    };
+    const log = spyOn(console, 'log').mockImplementation(() => {});
+    try {
+      const options = {
+        rootDir: root,
+        projectDir: root,
+        pluginVersion: '3.1.0',
+        registryClient,
+      };
+      expect(
+        await marketplaceCommand(
+          ['install', 'community/dispatch-agent'],
+          options,
+        ),
+      ).toBe(0);
+      expect(
+        await marketplaceCommand(
+          ['update', 'community/dispatch-agent'],
+          options,
+        ),
+      ).toBe(0);
+      expect(calls).toEqual([
+        { selector: 'community/dispatch-agent', minimumVersion: undefined },
+        { selector: 'community/dispatch-agent', minimumVersion: '1.0.0' },
+      ]);
+    } finally {
+      log.mockRestore();
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('tool dispatches remote install/update and propagates cancellation', async () => {
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-dispatch-'));
+    const signals: AbortSignal[] = [];
+    try {
+      const service = new MarketplaceService({
+        rootDir: root,
+        pluginVersion: '3.1.0',
+        registryClient: clientFor(['1.0.0', '2.0.0'], signals),
+      });
+      const tool = createMarketplaceTool({
+        service,
+        projectDir: root,
+        shouldManageSession: () => true,
+      }).marketplace;
+      const abortSignal = new AbortController().signal;
+      const context = {
+        sessionID: 'orchestrator-session',
+        agent: 'orchestrator',
+        abort: abortSignal,
+      };
+      await tool.execute(
+        { action: 'install', packageId: 'community/dispatch-agent' },
+        context as never,
+      );
+      await tool.execute(
+        { action: 'update', packageId: 'community/dispatch-agent' },
+        context as never,
+      );
+      expect(service.show('community/dispatch-agent').manifest.version).toBe(
+        '2.0.0',
+      );
+      expect(signals).toHaveLength(2);
+      expect(signals[0]).toBe(abortSignal);
+
+      const controller = new AbortController();
+      controller.abort();
+      const cancelledService = new MarketplaceService({
+        rootDir: join(root, 'cancelled'),
+        pluginVersion: '3.1.0',
+        registryClient: {
+          download: async (_selector, _minimumVersion, signal) => {
+            expect(signal).toBe(controller.signal);
+            expect(signal?.aborted).toBe(true);
+            throw new Error('cancelled');
+          },
+        },
+      });
+      const cancelledTool = createMarketplaceTool({
+        service: cancelledService,
+        projectDir: root,
+        shouldManageSession: () => true,
+      }).marketplace;
+      await expect(
+        cancelledTool.execute(
+          { action: 'install', packageId: 'community/dispatch-agent' },
+          { ...context, abort: controller.signal } as never,
+        ),
+      ).rejects.toThrow('cancelled');
+      expect(cancelledService.list()).toEqual([]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+});

+ 411 - 0
src/marketplace/registry-client.test.ts

@@ -0,0 +1,411 @@
+import { describe, expect, test } from 'bun:test';
+import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import type { MarketplacePackageBundle } from '../marketplace-contract';
+import {
+  canonicalizeMarketplaceValue,
+  createMarketplaceRegistryEntry,
+  createMarketplaceRegistryIndex,
+  digestMarketplaceBundle,
+  MarketplaceManifestSummarySchema,
+  MarketplaceRegistryIndexSchema,
+  parseMarketplaceRegistryIndex,
+  parseMarketplaceRegistrySelector,
+  projectMarketplaceManifestSummary,
+  registryArtifactPath,
+  resolveMarketplaceRegistryEntry,
+} from '../marketplace-contract';
+import {
+  MarketplaceRegistryIntegrityError,
+  MarketplaceRegistryNotFoundError,
+  MarketplaceRegistryProtocolError,
+  MarketplaceRegistryUnavailableError,
+} from './errors';
+import {
+  DEFAULT_MARKETPLACE_REGISTRY_ARTIFACT_MAX_BYTES,
+  MARKETPLACE_REGISTRY_INDEX_URL,
+  MarketplaceRegistryClient,
+} from './registry-client';
+import { MarketplaceService } from './service';
+
+function bundle(
+  version = '1.0.0',
+  id = 'community/registry-agent',
+  plugin = '>=3.0.0',
+): MarketplacePackageBundle {
+  return {
+    manifest: {
+      schemaVersion: 1,
+      id,
+      version,
+      kind: 'agent',
+      displayName: 'Registry agent',
+      description: 'A registry test package.',
+      instructions: 'Use the explorer role.',
+      author: { name: 'Community' },
+      tags: ['registry'],
+      license: 'MIT',
+      compatibility: { plugin, roleContract: '^1.0.0' },
+      routing: {
+        description: 'Explore registry fixtures.',
+        keywords: ['registry'],
+        delegation: {
+          when: 'When testing registry installs.',
+          preferredRoles: [],
+        },
+      },
+      requirements: {
+        skills: { required: [], optional: [] },
+        mcps: { required: [], optional: [] },
+      },
+      capabilities: { tools: [], permissions: [] },
+      baseRole: 'explorer',
+      agentName: 'registryagent',
+      overrides: {},
+    },
+  };
+}
+
+function indexFor(packageBundle = bundle()): Record<string, unknown> {
+  const manifest = packageBundle.manifest;
+  return {
+    schemaVersion: 1,
+    entries: [
+      {
+        id: manifest.id,
+        version: manifest.version,
+        artifactPath: registryArtifactPath(manifest.id, manifest.version),
+        digest: {
+          algorithm: 'sha256',
+          domain: 'marketplace-bundle-v1',
+          value: digestMarketplaceBundle(packageBundle),
+        },
+        summary: projectMarketplaceManifestSummary(manifest),
+      },
+    ],
+  };
+}
+
+function response(value: unknown, status = 200): Response {
+  return new Response(JSON.stringify(value), {
+    status,
+    headers: { 'content-type': 'application/json' },
+  });
+}
+
+describe('marketplace registry contract', () => {
+  test('enforces deterministic sorted unique entries and projections', () => {
+    const first = indexFor(bundle('1.0.0'));
+    const second = indexFor(bundle('2.0.0'));
+    const validIndex = {
+      schemaVersion: 1,
+      entries: [
+        ...(first.entries as unknown[]),
+        ...(second.entries as unknown[]),
+      ],
+    };
+    expect(MarketplaceRegistryIndexSchema.safeParse(validIndex).success).toBe(
+      true,
+    );
+    expect(
+      MarketplaceManifestSummarySchema.safeParse(
+        (first.entries as any)[0].summary,
+      ).success,
+    ).toBe(true);
+    expect(() =>
+      parseMarketplaceRegistryIndex({
+        ...validIndex,
+        entries: [...(validIndex.entries as unknown[]).reverse()],
+      }),
+    ).toThrow('sorted');
+    expect(() =>
+      parseMarketplaceRegistryIndex({
+        ...first,
+        entries: [
+          {
+            ...(first.entries as any)[0],
+            artifactPath: 'https://evil.example/artifact.json',
+          },
+        ],
+      }),
+    ).toThrow();
+    expect(() =>
+      parseMarketplaceRegistryIndex({
+        ...first,
+        entries: [
+          ...(first.entries as unknown[]),
+          ...(first.entries as unknown[]),
+        ],
+      }),
+    ).toThrow('Duplicate');
+  });
+
+  test('parses exact selectors and resolves the highest compatible version', () => {
+    expect(
+      parseMarketplaceRegistrySelector('Community/Registry-Agent@2.0.0'),
+    ).toEqual({
+      id: 'community/registry-agent',
+      version: '2.0.0',
+    });
+    const index = parseMarketplaceRegistryIndex({
+      schemaVersion: 1,
+      entries: [
+        ...(indexFor(bundle('1.0.0')).entries as unknown[]),
+        ...(indexFor(bundle('2.0.0')).entries as unknown[]),
+      ],
+    });
+    expect(
+      resolveMarketplaceRegistryEntry(
+        index,
+        { id: 'community/registry-agent' },
+        { pluginVersion: '3.1.0', roleContractVersion: '1.0.0' },
+      ).version,
+    ).toBe('2.0.0');
+    expect(() =>
+      parseMarketplaceRegistrySelector('community/registry-agent@^1.0.0'),
+    ).toThrow();
+    expect(() =>
+      parseMarketplaceRegistrySelector('community/registry-agent@v1.0.0'),
+    ).toThrow();
+  });
+
+  test('uses locale-independent code-unit ordering for JSON and catalog entries', () => {
+    expect(canonicalizeMarketplaceValue({ a_: 1, 'a-': 2 })).toBe(
+      '{"a-":2,"a_":1}',
+    );
+    const left = createMarketplaceRegistryEntry(bundle('1.0.0', 'a_a/pkg'));
+    const right = createMarketplaceRegistryEntry(bundle('1.0.0', 'a-a/pkg'));
+    const index = createMarketplaceRegistryIndex([left, right]);
+    expect(index.entries.map((entry) => entry.id)).toEqual([
+      'a-a/pkg',
+      'a_a/pkg',
+    ]);
+  });
+});
+
+describe('MarketplaceRegistryClient', () => {
+  test('validates the index and artifact before store mutation and records provenance', async () => {
+    const packageBundle = bundle();
+    const calls: string[] = [];
+    const client = new MarketplaceRegistryClient({
+      pluginVersion: '3.1.0',
+      fetch: async (input) => {
+        calls.push(String(input));
+        return calls.length === 1
+          ? response(indexFor(packageBundle))
+          : response(packageBundle);
+      },
+    });
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-registry-'));
+    try {
+      const service = new MarketplaceService({
+        rootDir: root,
+        pluginVersion: '3.1.0',
+        registryClient: client,
+      });
+      const installed = await service.installRemote('community/registry-agent');
+      expect(installed.manifest.version).toBe('1.0.0');
+      expect(
+        service.store.getLockfile().packages['community/registry-agent'].source,
+      ).toEqual({
+        kind: 'registry',
+        registry: 'https://registry.ohmyopencodeslim.com/v1/',
+        indexUrl: MARKETPLACE_REGISTRY_INDEX_URL,
+        packageUrl:
+          'https://registry.ohmyopencodeslim.com/v1/artifacts/community/registry-agent/1.0.0.json',
+      });
+      expect(calls).toEqual([
+        MARKETPLACE_REGISTRY_INDEX_URL,
+        'https://registry.ohmyopencodeslim.com/v1/artifacts/community/registry-agent/1.0.0.json',
+      ]);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('rejects digest, summary, redirect, and bounded responses', async () => {
+    const packageBundle = bundle();
+    const badIndex = indexFor(packageBundle);
+    (badIndex.entries as any)[0].digest.value = '0'.repeat(64);
+    const client = new MarketplaceRegistryClient({
+      pluginVersion: '3.1.0',
+      fetch: async (input) =>
+        String(input) === MARKETPLACE_REGISTRY_INDEX_URL
+          ? response(badIndex)
+          : response(packageBundle),
+    });
+    await expect(
+      client.download('community/registry-agent'),
+    ).rejects.toBeInstanceOf(MarketplaceRegistryIntegrityError);
+
+    const mismatchedSummary = indexFor(packageBundle);
+    (mismatchedSummary.entries as any)[0].summary.displayName = 'Forged';
+    const summaryClient = new MarketplaceRegistryClient({
+      pluginVersion: '3.1.0',
+      fetch: async (input) =>
+        String(input) === MARKETPLACE_REGISTRY_INDEX_URL
+          ? response(mismatchedSummary)
+          : response(packageBundle),
+    });
+    await expect(
+      summaryClient.download('community/registry-agent'),
+    ).rejects.toBeInstanceOf(MarketplaceRegistryIntegrityError);
+
+    const redirecting = new MarketplaceRegistryClient({
+      pluginVersion: '3.1.0',
+      fetch: async () => response({}, 302),
+    });
+    await expect(redirecting.fetchIndex()).rejects.toBeInstanceOf(
+      MarketplaceRegistryProtocolError,
+    );
+
+    const unavailable = new MarketplaceRegistryClient({
+      pluginVersion: '3.1.0',
+      fetch: async () => response({}, 503),
+    });
+    await expect(unavailable.fetchIndex()).rejects.toBeInstanceOf(
+      MarketplaceRegistryUnavailableError,
+    );
+    const notFound = new MarketplaceRegistryClient({
+      pluginVersion: '3.1.0',
+      fetch: async () => response({}, 404),
+    });
+    await expect(notFound.fetchIndex()).rejects.toBeInstanceOf(
+      MarketplaceRegistryNotFoundError,
+    );
+
+    const oversized = new MarketplaceRegistryClient({
+      pluginVersion: '3.1.0',
+      maxIndexBytes: 10,
+      fetch: async () => response({ schemaVersion: 1, entries: [] }),
+    });
+    await expect(oversized.fetchIndex()).rejects.toBeInstanceOf(
+      MarketplaceRegistryProtocolError,
+    );
+    const timedOut = new MarketplaceRegistryClient({
+      pluginVersion: '3.1.0',
+      timeoutMs: 1,
+      fetch: async (_input, init) =>
+        await new Promise<Response>((_resolve, reject) => {
+          init?.signal?.addEventListener('abort', () =>
+            reject(new Error('aborted')),
+          );
+        }),
+    });
+    await expect(timedOut.fetchIndex()).rejects.toBeInstanceOf(
+      MarketplaceRegistryUnavailableError,
+    );
+    expect(DEFAULT_MARKETPLACE_REGISTRY_ARTIFACT_MAX_BYTES).toBeGreaterThan(0);
+  });
+
+  test('local reads do not call the registry and updates are monotonic', async () => {
+    let calls = 0;
+    const packageBundle = bundle();
+    const client = new MarketplaceRegistryClient({
+      pluginVersion: '3.1.0',
+      fetch: async () => {
+        calls += 1;
+        return response(indexFor(packageBundle));
+      },
+    });
+    const root = mkdtempSync(join(tmpdir(), 'marketplace-offline-'));
+    try {
+      const service = new MarketplaceService({
+        rootDir: root,
+        registryClient: client,
+      });
+      service.importFile(join(root, 'missing.json'));
+    } catch {
+      // The missing local source is intentionally a local failure.
+    }
+    try {
+      expect(calls).toBe(0);
+      const service = new MarketplaceService({
+        rootDir: root,
+        registryClient: client,
+      });
+      expect(service.list()).toEqual([]);
+      expect(calls).toBe(0);
+      await expect(
+        service.updateRemote('community/registry-agent'),
+      ).rejects.toThrow();
+      expect(calls).toBe(0);
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('preserves the installed state after every remote validation failure', async () => {
+    const interruptedBody = new ReadableStream<Uint8Array>({
+      start(controller) {
+        controller.enqueue(new TextEncoder().encode('{"schemaVersion":1'));
+        controller.error(new Error('connection interrupted'));
+      },
+    });
+    const failures = [
+      async () => response({ schemaVersion: 1, entries: [] }),
+      async (input: RequestInfo | URL) => {
+        const index = indexFor(bundle());
+        (index.entries as any)[0].digest.value = '0'.repeat(64);
+        return String(input) === MARKETPLACE_REGISTRY_INDEX_URL
+          ? response(index)
+          : response(bundle());
+      },
+      async (input: RequestInfo | URL) => {
+        const index = indexFor(bundle());
+        (index.entries as any)[0].summary.displayName = 'Forged';
+        return String(input) === MARKETPLACE_REGISTRY_INDEX_URL
+          ? response(index)
+          : response(bundle());
+      },
+      async (input: RequestInfo | URL) => {
+        const incompatible = bundle(
+          '1.0.0',
+          'community/registry-agent',
+          '>=99.0.0',
+        );
+        return String(input) === MARKETPLACE_REGISTRY_INDEX_URL
+          ? response(indexFor(incompatible))
+          : response(incompatible);
+      },
+      async (input: RequestInfo | URL) =>
+        String(input) === MARKETPLACE_REGISTRY_INDEX_URL
+          ? response(indexFor(bundle()))
+          : new Response(interruptedBody, { status: 200 }),
+    ];
+
+    for (const failure of failures) {
+      const root = mkdtempSync(join(tmpdir(), 'marketplace-preserve-'));
+      try {
+        const service = new MarketplaceService({
+          rootDir: root,
+          pluginVersion: '3.1.0',
+          registryClient: new MarketplaceRegistryClient({
+            pluginVersion: '3.1.0',
+            fetch: failure,
+          }),
+        });
+        const installed = service.install(bundle());
+        const beforeLock = readFileSync(service.store.paths.lockfilePath);
+        const beforePackage = readFileSync(
+          join(installed.path, 'package.json'),
+        );
+        await expect(
+          service.installRemote('community/registry-agent'),
+        ).rejects.toThrow();
+        expect(readFileSync(service.store.paths.lockfilePath)).toEqual(
+          beforeLock,
+        );
+        expect(readFileSync(join(installed.path, 'package.json'))).toEqual(
+          beforePackage,
+        );
+        expect(service.show('community/registry-agent').manifest.version).toBe(
+          '1.0.0',
+        );
+      } finally {
+        rmSync(root, { recursive: true, force: true });
+      }
+    }
+  });
+});

+ 301 - 0
src/marketplace/registry-client.ts

@@ -0,0 +1,301 @@
+import { satisfies } from 'semver';
+import {
+  DEFAULT_MARKETPLACE_REGISTRY_URL,
+  MarketplacePackageBundleSchema,
+  type MarketplaceRegistryEntry,
+  type MarketplaceRegistryIndex,
+  parseMarketplaceRegistryIndex,
+  parseMarketplaceRegistrySelector,
+  resolveMarketplaceRegistryEntry,
+  validateMarketplaceRegistryEntry,
+} from '../marketplace-contract';
+import {
+  MarketplaceCompatibilityError,
+  MarketplaceRegistryIntegrityError,
+  MarketplaceRegistryNotFoundError,
+  MarketplaceRegistryProtocolError,
+  MarketplaceRegistryUnavailableError,
+} from './errors';
+import {
+  MARKETPLACE_ROLE_CONTRACT_VERSION,
+  type MarketplacePackageBundle,
+} from './schemas';
+
+export const MARKETPLACE_REGISTRY_INDEX_URL = `${DEFAULT_MARKETPLACE_REGISTRY_URL}index.json`;
+export const DEFAULT_MARKETPLACE_REGISTRY_TIMEOUT_MS = 10_000;
+export const DEFAULT_MARKETPLACE_REGISTRY_INDEX_MAX_BYTES = 2 * 1024 * 1024;
+export const DEFAULT_MARKETPLACE_REGISTRY_ARTIFACT_MAX_BYTES = 512 * 1024;
+
+export interface MarketplaceRegistryClientOptions {
+  pluginVersion: string;
+  roleContractVersion?: string;
+  timeoutMs?: number;
+  maxIndexBytes?: number;
+  maxArtifactBytes?: number;
+  fetch?: typeof globalThis.fetch;
+}
+
+export interface MarketplaceRegistryDownload {
+  bundle: MarketplacePackageBundle;
+  entry: MarketplaceRegistryEntry;
+  indexUrl: string;
+  packageUrl: string;
+}
+
+async function readBoundedBody(
+  response: Response,
+  maxBytes: number,
+  url: string,
+): Promise<string> {
+  const contentLength = response.headers.get('content-length');
+  if (contentLength && Number(contentLength) > maxBytes) {
+    throw new MarketplaceRegistryProtocolError(
+      `Registry response exceeds the ${maxBytes}-byte limit: ${url}`,
+    );
+  }
+  if (!response.body) {
+    const text = await response.text();
+    if (new TextEncoder().encode(text).byteLength > maxBytes) {
+      throw new MarketplaceRegistryProtocolError(
+        `Registry response exceeds the ${maxBytes}-byte limit: ${url}`,
+      );
+    }
+    return text;
+  }
+  const reader = response.body.getReader();
+  const chunks: Uint8Array[] = [];
+  let size = 0;
+  try {
+    while (true) {
+      const next = await reader.read();
+      if (next.done) break;
+      size += next.value.byteLength;
+      if (size > maxBytes) {
+        await reader.cancel();
+        throw new MarketplaceRegistryProtocolError(
+          `Registry response exceeds the ${maxBytes}-byte limit: ${url}`,
+        );
+      }
+      chunks.push(next.value);
+    }
+  } finally {
+    reader.releaseLock();
+  }
+  const bytes = new Uint8Array(size);
+  let offset = 0;
+  for (const chunk of chunks) {
+    bytes.set(chunk, offset);
+    offset += chunk.byteLength;
+  }
+  return new TextDecoder().decode(bytes);
+}
+
+export class MarketplaceRegistryClient {
+  private readonly fetcher: typeof globalThis.fetch;
+  private readonly timeoutMs: number;
+  private readonly maxIndexBytes: number;
+  private readonly maxArtifactBytes: number;
+  private readonly roleContractVersion: string;
+
+  constructor(private readonly options: MarketplaceRegistryClientOptions) {
+    this.fetcher = options.fetch ?? globalThis.fetch;
+    this.timeoutMs =
+      options.timeoutMs ?? DEFAULT_MARKETPLACE_REGISTRY_TIMEOUT_MS;
+    this.maxIndexBytes =
+      options.maxIndexBytes ?? DEFAULT_MARKETPLACE_REGISTRY_INDEX_MAX_BYTES;
+    this.maxArtifactBytes =
+      options.maxArtifactBytes ??
+      DEFAULT_MARKETPLACE_REGISTRY_ARTIFACT_MAX_BYTES;
+    this.roleContractVersion =
+      options.roleContractVersion ?? MARKETPLACE_ROLE_CONTRACT_VERSION;
+  }
+
+  async fetchIndex(signal?: AbortSignal): Promise<MarketplaceRegistryIndex> {
+    const text = await this.fetchJson(
+      MARKETPLACE_REGISTRY_INDEX_URL,
+      this.maxIndexBytes,
+      signal,
+    );
+    try {
+      return parseMarketplaceRegistryIndex(text);
+    } catch (error) {
+      throw new MarketplaceRegistryProtocolError(
+        error instanceof Error ? error.message : String(error),
+      );
+    }
+  }
+
+  async download(
+    selectorText: string,
+    minimumVersion?: string,
+    signal?: AbortSignal,
+  ): Promise<MarketplaceRegistryDownload> {
+    if (signal?.aborted) {
+      throw new MarketplaceRegistryUnavailableError(
+        'Marketplace registry request was cancelled',
+      );
+    }
+    const selector = (() => {
+      try {
+        return parseMarketplaceRegistrySelector(selectorText);
+      } catch (error) {
+        throw new MarketplaceRegistryProtocolError(
+          error instanceof Error ? error.message : String(error),
+        );
+      }
+    })();
+    const index = await this.fetchIndex(signal);
+    const matchingId = index.entries.some((entry) => entry.id === selector.id);
+    if (!matchingId) {
+      throw new MarketplaceRegistryNotFoundError(
+        `Marketplace package ${selector.id} was not found in the registry`,
+      );
+    }
+    if (
+      selector.version &&
+      !index.entries.some(
+        (entry) =>
+          entry.id === selector.id && entry.version === selector.version,
+      )
+    ) {
+      throw new MarketplaceRegistryNotFoundError(
+        `Marketplace package ${selector.id}@${selector.version} was not found in the registry`,
+      );
+    }
+    let entry: MarketplaceRegistryEntry;
+    try {
+      entry = resolveMarketplaceRegistryEntry(
+        index,
+        selector,
+        {
+          pluginVersion: this.options.pluginVersion,
+          roleContractVersion: this.roleContractVersion,
+        },
+        minimumVersion,
+      );
+    } catch (error) {
+      throw new MarketplaceCompatibilityError(
+        error instanceof Error ? error.message : String(error),
+      );
+    }
+
+    const packageUrl = new URL(
+      entry.artifactPath,
+      DEFAULT_MARKETPLACE_REGISTRY_URL,
+    ).href;
+    const artifact = await this.fetchJson(
+      packageUrl,
+      this.maxArtifactBytes,
+      signal,
+    );
+    let bundle: MarketplacePackageBundle;
+    try {
+      const result = MarketplacePackageBundleSchema.safeParse(artifact);
+      if (!result.success) {
+        throw new Error(result.error.message);
+      }
+      bundle = result.data;
+      validateMarketplaceRegistryEntry(entry, bundle);
+    } catch (error) {
+      throw new MarketplaceRegistryIntegrityError(
+        error instanceof Error ? error.message : String(error),
+      );
+    }
+    if (!this.isCompatible(bundle)) {
+      throw new MarketplaceCompatibilityError(
+        `${bundle.manifest.id}@${bundle.manifest.version} is incompatible with this plugin`,
+      );
+    }
+    if (signal?.aborted) {
+      throw new MarketplaceRegistryUnavailableError(
+        'Marketplace registry request was cancelled',
+      );
+    }
+    return {
+      bundle,
+      entry,
+      indexUrl: MARKETPLACE_REGISTRY_INDEX_URL,
+      packageUrl,
+    };
+  }
+
+  private isCompatible(bundle: MarketplacePackageBundle): boolean {
+    return (
+      satisfies(
+        this.options.pluginVersion,
+        bundle.manifest.compatibility.plugin,
+      ) &&
+      satisfies(
+        this.roleContractVersion,
+        bundle.manifest.compatibility.roleContract,
+      )
+    );
+  }
+
+  private async fetchJson(
+    url: string,
+    maxBytes: number,
+    externalSignal?: AbortSignal,
+  ): Promise<unknown> {
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
+    const abortExternal = () => controller.abort();
+    if (externalSignal?.aborted) controller.abort();
+    externalSignal?.addEventListener('abort', abortExternal, { once: true });
+    try {
+      let response: Response;
+      try {
+        response = await this.fetcher(url, {
+          redirect: 'manual',
+          signal: controller.signal,
+        });
+      } catch (error) {
+        throw new MarketplaceRegistryUnavailableError(
+          `Registry request failed for ${url}: ${error instanceof Error ? error.message : String(error)}`,
+        );
+      }
+      if (response.status >= 300 && response.status < 400) {
+        throw new MarketplaceRegistryProtocolError(
+          `Registry redirects are not permitted: ${url}`,
+        );
+      }
+      if (response.status === 404) {
+        throw new MarketplaceRegistryNotFoundError(
+          `Registry resource not found: ${url}`,
+        );
+      }
+      if (response.status !== 200) {
+        throw new MarketplaceRegistryUnavailableError(
+          `Registry returned HTTP ${response.status}: ${url}`,
+        );
+      }
+      const text = await readBoundedBody(response, maxBytes, url);
+      if (controller.signal.aborted || externalSignal?.aborted) {
+        throw new MarketplaceRegistryUnavailableError(
+          `Registry request was cancelled: ${url}`,
+        );
+      }
+      try {
+        return JSON.parse(text) as unknown;
+      } catch (error) {
+        throw new MarketplaceRegistryProtocolError(
+          `Registry returned invalid JSON for ${url}: ${error instanceof Error ? error.message : String(error)}`,
+        );
+      }
+    } catch (error) {
+      if (
+        error instanceof MarketplaceRegistryProtocolError ||
+        error instanceof MarketplaceRegistryNotFoundError ||
+        error instanceof MarketplaceRegistryUnavailableError
+      ) {
+        throw error;
+      }
+      throw new MarketplaceRegistryUnavailableError(
+        `Registry request failed for ${url}: ${error instanceof Error ? error.message : String(error)}`,
+      );
+    } finally {
+      clearTimeout(timeout);
+      externalSignal?.removeEventListener('abort', abortExternal);
+    }
+  }
+}

+ 15 - 0
src/marketplace/schemas.test.ts

@@ -3,6 +3,7 @@ import {
   MarketplaceAgentManifestSchema,
   MarketplacePackageManifestSchema,
   MarketplaceProfileManifestSchema,
+  MarketplaceVersionSchema,
 } from './schemas';
 
 const common = {
@@ -32,6 +33,20 @@ const common = {
 };
 
 describe('marketplace manifest schemas', () => {
+  test('rejects non-canonical semantic-version aliases', () => {
+    expect(MarketplaceVersionSchema.safeParse('v1.0.0').success).toBe(false);
+    expect(
+      MarketplacePackageManifestSchema.safeParse({
+        ...common,
+        version: 'v1.0.0',
+        kind: 'agent',
+        baseRole: 'explorer',
+        agentName: 'example',
+        overrides: {},
+      }).success,
+    ).toBe(false);
+  });
+
   test('accept only data-only agent and profile manifests', () => {
     expect(
       MarketplaceAgentManifestSchema.safeParse({

+ 8 - 3
src/marketplace/schemas.ts

@@ -22,8 +22,12 @@ export const MarketplacePackageIdSchema = z
 export const MarketplaceVersionSchema = z
   .string()
   .refine(
-    (version) => valid(version) !== null,
-    'Expected an exact semantic version',
+    (version) =>
+      valid(version) !== null &&
+      /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(
+        version,
+      ),
+    'Expected canonical exact semantic version spelling',
   );
 
 export const MarketplaceRoleSchema = z.enum(SUPPORTED_SPECIALIST_ROLES);
@@ -221,7 +225,8 @@ export const MarketplaceRegistrySourceSchema = z
   .object({
     kind: z.literal('registry'),
     registry: z.string().trim().min(1).max(200),
-    packageUrl: z.string().url().optional(),
+    indexUrl: z.string().url(),
+    packageUrl: z.string().url(),
   })
   .strict();
 

+ 73 - 0
src/marketplace/service.ts

@@ -1,4 +1,6 @@
 import { readFileSync, realpathSync } from 'node:fs';
+import { DEFAULT_MARKETPLACE_REGISTRY_URL } from '../marketplace-contract';
+import { readPluginPackageVersion } from '../utils/package-metadata';
 import type { MarketplaceCompatibilityOptions } from './compatibility';
 import {
   type MarketplaceConfigReference,
@@ -6,9 +8,12 @@ import {
 } from './config-references';
 import {
   MarketplaceActivationReferenceError,
+  MarketplaceConflictError,
+  MarketplaceRegistryUnavailableError,
   MarketplaceValidationError,
 } from './errors';
 import { normalizeMarketplacePackageId } from './ids';
+import { MarketplaceRegistryClient } from './registry-client';
 import {
   type MarketplacePackageBundle,
   MarketplacePackageBundleSchema,
@@ -26,6 +31,7 @@ export interface MarketplaceServiceOptions
   extends MarketplaceStoreOptions,
     MarketplaceCompatibilityOptions {
   projectDir?: string;
+  registryClient?: Pick<MarketplaceRegistryClient, 'download'>;
 }
 
 export interface MarketplaceRemoveOptions {
@@ -66,10 +72,18 @@ function referenceMessage(
 export class MarketplaceService {
   readonly store: MarketplaceStore;
   readonly projectDir: string;
+  readonly registryClient: Pick<MarketplaceRegistryClient, 'download'>;
 
   constructor(options: MarketplaceServiceOptions = {}) {
     this.store = new MarketplaceStore(options);
     this.projectDir = options.projectDir ?? process.cwd();
+    this.registryClient =
+      options.registryClient ??
+      new MarketplaceRegistryClient({
+        pluginVersion:
+          options.pluginVersion ?? readPluginPackageVersion() ?? '0.0.0',
+        roleContractVersion: options.roleContractVersion,
+      });
   }
 
   install(
@@ -87,6 +101,10 @@ export class MarketplaceService {
     );
   }
 
+  importFile(filePath: string): StoredMarketplacePackage {
+    return this.installFile(filePath);
+  }
+
   update(
     input: MarketplacePackageBundle,
     source?: MarketplaceSource,
@@ -102,6 +120,61 @@ export class MarketplaceService {
     );
   }
 
+  importFileUpdate(filePath: string): StoredMarketplacePackage {
+    return this.updateFile(filePath);
+  }
+
+  async installRemote(
+    selector: string,
+    signal?: AbortSignal,
+  ): Promise<StoredMarketplacePackage> {
+    const downloaded = await this.registryClient.download(
+      selector,
+      undefined,
+      signal,
+    );
+    if (signal?.aborted) {
+      throw new MarketplaceRegistryUnavailableError(
+        'Marketplace install was cancelled before local mutation',
+      );
+    }
+    return this.store.install(downloaded.bundle, {
+      kind: 'registry',
+      registry: DEFAULT_MARKETPLACE_REGISTRY_URL,
+      indexUrl: downloaded.indexUrl,
+      packageUrl: downloaded.packageUrl,
+    });
+  }
+
+  async updateRemote(
+    id: string,
+    signal?: AbortSignal,
+  ): Promise<StoredMarketplacePackage> {
+    const normalizedId = normalizeMarketplacePackageId(id);
+    if (!this.store.getLockfile().packages[normalizedId]) {
+      throw new MarketplaceConflictError(
+        `${normalizedId} is not installed; updates require an existing package`,
+      );
+    }
+    const current = this.store.show(normalizedId);
+    const downloaded = await this.registryClient.download(
+      current.manifest.id,
+      current.manifest.version,
+      signal,
+    );
+    if (signal?.aborted) {
+      throw new MarketplaceRegistryUnavailableError(
+        'Marketplace update was cancelled before local mutation',
+      );
+    }
+    return this.store.update(downloaded.bundle, {
+      kind: 'registry',
+      registry: DEFAULT_MARKETPLACE_REGISTRY_URL,
+      indexUrl: downloaded.indexUrl,
+      packageUrl: downloaded.packageUrl,
+    });
+  }
+
   list(): StoredMarketplacePackage[] {
     return this.store.list();
   }

+ 52 - 0
src/marketplace/store.test.ts

@@ -582,6 +582,58 @@ describe('MarketplaceService', () => {
     }
   });
 
+  test('updates only to a strictly newer version and preserve state on rejection', () => {
+    const root = tempRoot();
+    try {
+      const service = new MarketplaceService({ rootDir: root });
+      expect(() => service.update(bundle('1.0.0'))).toThrow(
+        MarketplaceConflictError,
+      );
+      service.install(bundle('2.0.0'));
+      expect(() => service.update(bundle('2.0.0'))).toThrow(
+        MarketplaceConflictError,
+      );
+      expect(() => service.update(bundle('1.0.0'))).toThrow(
+        MarketplaceConflictError,
+      );
+      expect(service.show('community/example').manifest.version).toBe('2.0.0');
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
+  test('preserves locked bytes when same-version install has a different digest', () => {
+    const root = tempRoot();
+    try {
+      const service = new MarketplaceService({ rootDir: root });
+      const installed = service.install(bundle());
+      const lockBefore = readFileSync(service.store.paths.lockfilePath);
+      const packageBefore = readFileSync(join(installed.path, 'package.json'));
+      const digestBefore = readFileSync(join(installed.path, 'sha256'));
+
+      expect(() =>
+        service.install(
+          bundle('1.0.0', { instructions: 'A different package body.' }),
+        ),
+      ).toThrow(MarketplaceConflictError);
+
+      expect(readFileSync(service.store.paths.lockfilePath)).toEqual(
+        lockBefore,
+      );
+      expect(readFileSync(join(installed.path, 'package.json'))).toEqual(
+        packageBefore,
+      );
+      expect(readFileSync(join(installed.path, 'sha256'))).toEqual(
+        digestBefore,
+      );
+      expect(service.show('community/example').digest).toBe(
+        digestBefore.toString().trim(),
+      );
+    } finally {
+      rmSync(root, { recursive: true, force: true });
+    }
+  });
+
   test('preserves the canonical local import source and exposes show', () => {
     const root = tempRoot();
     const packageFile = join(root, 'package.json');

+ 33 - 34
src/marketplace/store.ts

@@ -1,6 +1,15 @@
-import { createHash, randomUUID } from 'node:crypto';
+import { randomUUID } from 'node:crypto';
 import * as fs from 'node:fs';
 import * as path from 'node:path';
+import { gt } from 'semver';
+import {
+  canonicalizeMarketplaceValue,
+  compareMarketplaceCodeUnits,
+  digestMarketplaceBundle,
+} from './canonical';
+
+export { digestMarketplaceBundle } from './canonical';
+
 import { validateMarketplaceCompatibility } from './compatibility';
 import {
   MarketplaceConflictError,
@@ -62,35 +71,8 @@ export interface MarketplaceStoreInspection {
   operationalError?: string;
 }
 
-function stableJson(value: unknown): string {
-  if (Array.isArray(value)) {
-    return `[${value.map((item) => stableJson(item)).join(',')}]`;
-  }
-  if (value !== null && typeof value === 'object') {
-    const entries = Object.entries(value as Record<string, unknown>).sort(
-      ([a], [b]) => a.localeCompare(b),
-    );
-    return `{${entries
-      .map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
-      .join(',')}}`;
-  }
-  return JSON.stringify(value);
-}
-
 /** Digest input is UTF-8 bytes of canonical stable JSON for `{ manifest }`. */
-export function canonicalMarketplaceBundleBytes(
-  bundle: MarketplacePackageBundle,
-): string {
-  return stableJson(bundle);
-}
-
-export function digestMarketplaceBundle(
-  bundle: MarketplacePackageBundle,
-): string {
-  return createHash('sha256')
-    .update(canonicalMarketplaceBundleBytes(bundle))
-    .digest('hex');
-}
+export const canonicalMarketplaceBundleBytes = canonicalizeMarketplaceValue;
 
 function parseBundle(value: unknown): MarketplacePackageBundle {
   const result = MarketplacePackageBundleSchema.safeParse(value);
@@ -392,10 +374,18 @@ export class MarketplaceStore {
           }
           if (
             mode === 'update' &&
-            current.manifestVersion === bundle.manifest.version
+            !gt(bundle.manifest.version, current.manifestVersion)
+          ) {
+            throw new MarketplaceConflictError(
+              `${bundle.manifest.id}@${bundle.manifest.version} is not strictly newer than the installed ${current.manifestVersion}`,
+            );
+          }
+          if (
+            current.manifestVersion === bundle.manifest.version &&
+            existing.digest !== digest
           ) {
             throw new MarketplaceConflictError(
-              `${bundle.manifest.id}@${bundle.manifest.version} is already selected; updates require a new exact version`,
+              `${bundle.manifest.id}@${bundle.manifest.version} is already locked with a different digest`,
             );
           }
           if (existing.digest === digest) {
@@ -408,6 +398,12 @@ export class MarketplaceStore {
           }
         }
 
+        if (mode === 'update' && !current) {
+          throw new MarketplaceConflictError(
+            `${bundle.manifest.id} is not installed; updates require an existing package`,
+          );
+        }
+
         const versionPath = packageVersionPath(
           this.paths,
           bundle.manifest.id,
@@ -629,7 +625,7 @@ export class MarketplaceStore {
       fs.mkdirSync(temporaryPath, { recursive: true });
       writeAtomic(
         path.join(temporaryPath, 'package.json'),
-        `${stableJson(bundle)}\n`,
+        `${canonicalizeMarketplaceValue(bundle)}\n`,
       );
       writeAtomic(path.join(temporaryPath, 'sha256'), `${digest}\n`);
       fs.mkdirSync(path.dirname(versionPath), { recursive: true });
@@ -651,11 +647,14 @@ export class MarketplaceStore {
       schemaVersion: MARKETPLACE_LOCKFILE_SCHEMA_VERSION,
       packages: Object.fromEntries(
         Object.entries(lockfile.packages).sort(([a], [b]) =>
-          a.localeCompare(b),
+          compareMarketplaceCodeUnits(a, b),
         ),
       ),
     };
-    writeAtomic(this.paths.lockfilePath, `${stableJson(normalized)}\n`);
+    writeAtomic(
+      this.paths.lockfilePath,
+      `${canonicalizeMarketplaceValue(normalized)}\n`,
+    );
   }
 
   private toStored(

+ 2 - 1
src/tools/codemap.md

@@ -8,7 +8,8 @@ Centralized tool factory and registry for the OpenCode plugin system. This direc
 - **Code intelligence tools**: AST-grep pattern matching and transformation across languages
 - **Web capabilities**: Smart web fetching with caching and secondary model processing
 - **ACP integration**: External agent protocol execution
-- **Marketplace**: Local offline package install, activation, and status
+- **Marketplace**: Explicit registry install/update plus local import,
+  activation, and offline status
 - **Preset switching**: On-disk preset persistence helpers used by the TUI `/preset` manager
 
 These tools enable agents to perform file operations, manage background tasks, and interact with external systems while maintaining security boundaries through the OpenCode tool schema. Multi-LLM council orchestration is agent-level (dynamic `councillor-<name>` subagents in `src/agents/`), not a tool.

+ 14 - 8
src/tools/marketplace.test.ts

@@ -189,11 +189,11 @@ describe('marketplace tool', () => {
 
       const installed = String(
         await marketplace.execute(
-          { action: 'install', path: agentPath },
+          { action: 'import', path: agentPath },
           context,
         ),
       );
-      expect(installed).toContain('Installed community/docs-researcher@1.0.0');
+      expect(installed).toContain('Imported community/docs-researcher@1.0.0');
       expect(installed).toContain('reload_required: false');
       expect(installed).not.toContain(MARKETPLACE_RELOAD_NOTICE);
 
@@ -203,7 +203,7 @@ describe('marketplace tool', () => {
           context,
         ),
       );
-      expect(imported).toContain('Installed community/deep-explorer@1.0.0');
+      expect(imported).toContain('Imported community/deep-explorer@1.0.0');
       expect(imported).toContain('reload_required: false');
 
       const listed = String(
@@ -236,7 +236,10 @@ describe('marketplace tool', () => {
       expect(verified).toContain('OK');
 
       const updated = String(
-        await marketplace.execute({ action: 'update', path: v2Path }, context),
+        await marketplace.execute(
+          { action: 'import', path: v2Path, update: true },
+          context,
+        ),
       );
       expect(updated).toContain('Updated community/docs-researcher@1.1.0');
       expect(updated).toContain('reload_required: false');
@@ -344,7 +347,7 @@ describe('marketplace tool', () => {
       setupHarness();
     try {
       const v1 = writeBundle(project, agentBundle());
-      await marketplace.execute({ action: 'install', path: v1 }, context);
+      await marketplace.execute({ action: 'import', path: v1 }, context);
       await marketplace.execute(
         { action: 'enable', packageId: 'community/docs-researcher' },
         context,
@@ -361,7 +364,10 @@ describe('marketplace tool', () => {
       ];
       const v2 = writeBundle(project, agentBundle('1.1.0'), 'package-v2.json');
       const updated = String(
-        await marketplace.execute({ action: 'update', path: v2 }, context),
+        await marketplace.execute(
+          { action: 'import', path: v2, update: true },
+          context,
+        ),
       );
       expect(updated).toContain('reload_required: true');
     } finally {
@@ -374,7 +380,7 @@ describe('marketplace tool', () => {
       setupHarness();
     try {
       const path = writeBundle(project, agentBundle());
-      await marketplace.execute({ action: 'install', path }, context);
+      await marketplace.execute({ action: 'import', path }, context);
       await marketplace.execute(
         { action: 'enable', packageId: 'community/docs-researcher' },
         context,
@@ -410,7 +416,7 @@ describe('marketplace tool', () => {
       setupHarness();
     try {
       const path = writeBundle(project, agentBundle());
-      await marketplace.execute({ action: 'install', path }, context);
+      await marketplace.execute({ action: 'import', path }, context);
       await marketplace.execute(
         { action: 'enable', packageId: 'community/docs-researcher' },
         context,

+ 41 - 15
src/tools/marketplace.ts

@@ -34,9 +34,19 @@ export const MARKETPLACE_TOOL_ACTIONS = [
 export type MarketplaceToolAction = (typeof MARKETPLACE_TOOL_ACTIONS)[number];
 
 const MarketplaceToolRequestSchema = z.discriminatedUnion('action', [
-  z.object({ action: z.literal('install'), path: z.string().min(1) }).strict(),
-  z.object({ action: z.literal('import'), path: z.string().min(1) }).strict(),
-  z.object({ action: z.literal('update'), path: z.string().min(1) }).strict(),
+  z
+    .object({ action: z.literal('install'), packageId: z.string().min(1) })
+    .strict(),
+  z
+    .object({
+      action: z.literal('import'),
+      path: z.string().min(1),
+      update: z.boolean().optional(),
+    })
+    .strict(),
+  z
+    .object({ action: z.literal('update'), packageId: z.string().min(1) })
+    .strict(),
   z
     .object({ action: z.literal('show'), packageId: z.string().min(1) })
     .strict(),
@@ -161,13 +171,13 @@ export function createMarketplaceTool(
   options: MarketplaceToolOptions,
 ): Record<'marketplace', ToolDefinition> {
   const marketplace = tool({
-    description: `Manage local offline marketplace packages.
+    description: `Manage marketplace packages.
 
-Use this tool for install, import, list, show, verify, update, enable, disable, profile, remove, and status. Do not shell out to the CLI for these actions. There is no network registry.
+Use install and update with a canonical package ID for the default HTTPS registry. Use import with a local path (and update=true for an existing package). Do not infer whether an argument is a path or registry ID. Do not shell out to the CLI for these actions.
 
-list, show, verify, and status are read-only. install, import, update, enable, disable, profile, and remove write the local store and plugin config only and never hot-swap the live agent registry. Those mutations report reload_required only when disk activation differs from this session; no-op or inactive changes do not.
+list, show, verify, and status are read-only and always offline. Remote network work occurs only for explicit install/update. All mutations use the local transaction store and never hot-swap the live agent registry.
 
-Action-specific fields: path for install/import/update; packageId for show/enable/disable/remove and optional for verify; role plus packageId or role plus clear=true for profile; no extra fields.`,
+Action-specific fields: packageId for install/update/show/enable/disable/profile/remove; path and optional update=true for import; role plus packageId or role plus clear=true for profile; no extra fields.`,
     args: {
       action: toolZ
         .enum(MARKETPLACE_TOOL_ACTIONS)
@@ -176,13 +186,13 @@ Action-specific fields: path for install/import/update; packageId for show/enabl
         .string()
         .min(1)
         .optional()
-        .describe('Local package.json path for install, import, or update'),
+        .describe('Local package.json path for explicit import'),
       packageId: toolZ
         .string()
         .min(1)
         .optional()
         .describe(
-          'Canonical package ID for show, verify, enable, disable, profile, or remove',
+          'Canonical package ID for registry install/update or local actions',
         ),
       role: toolZ
         .string()
@@ -197,6 +207,10 @@ Action-specific fields: path for install/import/update; packageId for show/enabl
         .boolean()
         .optional()
         .describe('Clear the selected profile for a specialist role'),
+      update: toolZ
+        .boolean()
+        .optional()
+        .describe('Use with import to update an existing local package'),
     },
     async execute(args, toolContext) {
       const sessionID = toolContext?.sessionID;
@@ -228,19 +242,31 @@ Action-specific fields: path for install/import/update; packageId for show/enabl
       const projectDir = options.projectDir;
 
       switch (request.action) {
-        case 'install':
-        case 'import': {
-          const pkg = service.installFile(
-            resolvePackagePath(projectDir, request.path),
+        case 'install': {
+          const pkg = await service.installRemote(
+            request.packageId,
+            toolContext?.abort,
           );
           return mutationResult(
             options,
             `Installed ${pkg.manifest.id}@${pkg.manifest.version}`,
           );
         }
+        case 'import': {
+          const pkg = request.update
+            ? service.importFileUpdate(
+                resolvePackagePath(projectDir, request.path),
+              )
+            : service.importFile(resolvePackagePath(projectDir, request.path));
+          return mutationResult(
+            options,
+            `${request.update ? 'Updated' : 'Imported'} ${pkg.manifest.id}@${pkg.manifest.version}`,
+          );
+        }
         case 'update': {
-          const pkg = service.updateFile(
-            resolvePackagePath(projectDir, request.path),
+          const pkg = await service.updateRemote(
+            request.packageId,
+            toolContext?.abort,
           );
           return mutationResult(
             options,

+ 10 - 6
src/tui-state.test.ts

@@ -419,9 +419,11 @@ describe('tui-state persistence', () => {
     expect(fs.statSync(filePath).mtimeMs).toBe(baselineMtime);
   });
 
-  test('repeated no-op updates do not touch the lock or the filesystem', async () => {
+  test('repeated no-op updates verify content without rewriting the filesystem', async () => {
     recordLuna();
     const fsModule = await import('node:fs');
+    const filePath = getTuiStatePath(tempDir);
+    const before = fs.statSync(filePath);
     let openCalls = 0;
     let readCalls = 0;
     const lockCreateSpy = spyOn(fsModule, 'openSync').mockImplementation(
@@ -439,8 +441,10 @@ describe('tui-state persistence', () => {
     try {
       recordLuna();
       recordLuna();
-      expect(openCalls).toBe(0);
-      expect(readCalls).toBe(0);
+      expect(openCalls).toBeGreaterThan(0);
+      expect(readCalls).toBeGreaterThan(0);
+      expect(fs.statSync(filePath).mtimeMs).toBe(before.mtimeMs);
+      expect(fs.existsSync(`${filePath}.lock`)).toBe(false);
     } finally {
       lockCreateSpy.mockRestore();
       readSpy.mockRestore();
@@ -509,9 +513,9 @@ describe('tui-state persistence', () => {
     recordTuiAgentModel({ agentName: 'explorer', model: 'model-x' }, tempDir);
     const statBefore = fs.statSync(filePath);
 
-    // In-place rewrite (same inode, same length): mtime restored via
-    // utimes. ctime cannot be restored by userspace, so the memo must
-    // invalidate and re-record the value from the real file.
+    // In-place rewrite (same inode, same length): mtime restored via utimes.
+    // The memo must verify the file content rather than rely on timestamp
+    // precision, then re-record the value from the real file.
     const external = readTuiSnapshot(tempDir);
     external.agentModels.explorer = 'model-y';
     const fd = fs.openSync(filePath, 'w');

+ 47 - 17
src/tui-state.ts

@@ -245,33 +245,36 @@ function releaseStateLock(lock: TuiStateLock): void {
   }
 }
 
-// Last confirmed on-disk snapshot per project, keyed by identity
-// (ino,mtime,size). No-ops return before the lock; failed writes do not
-// seed the memo. An identity mismatch (external rename) invalidates it.
+// Last confirmed on-disk snapshot per project, keyed by filesystem identity
+// (ino,mtime,size,ctime) and the file content. No-ops return before the lock;
+// failed writes do not seed the memo. Metadata alone cannot detect every
+// in-place rewrite (some filesystems coalesce ctime updates), so the memo also
+// verifies the content without relying on timestamp precision.
 const lastKnownSnapshots = new Map<
   string,
   {
     snapshot: TuiSnapshot;
-    ino: number;
-    mtimeMs: number;
-    ctimeMs: number;
-    size: number;
+    ino: bigint;
+    mtimeNs: bigint;
+    ctimeNs: bigint;
+    size: bigint;
+    contentFingerprint: string;
   }
 >();
 const LAST_KNOWN_SNAPSHOTS_MAX = 32;
 
 function statSnapshotFile(statePath: string): {
-  ino: number;
-  mtimeMs: number;
-  ctimeMs: number;
-  size: number;
+  ino: bigint;
+  mtimeNs: bigint;
+  ctimeNs: bigint;
+  size: bigint;
 } | null {
   try {
-    const stat = fs.statSync(statePath);
+    const stat = fs.statSync(statePath, { bigint: true });
     return {
       ino: stat.ino,
-      mtimeMs: stat.mtimeMs,
-      ctimeMs: stat.ctimeMs,
+      mtimeNs: stat.mtimeNs,
+      ctimeNs: stat.ctimeNs,
       size: stat.size,
     };
   } catch {
@@ -279,6 +282,10 @@ function statSnapshotFile(statePath: string): {
   }
 }
 
+function fingerprintSnapshotContent(content: string): string {
+  return createHash('sha256').update(content, 'utf8').digest('hex');
+}
+
 function cloneSnapshot(snapshot: TuiSnapshot): TuiSnapshot {
   return {
     version: snapshot.version,
@@ -307,6 +314,15 @@ function rememberSnapshot(statePath: string, snapshot: TuiSnapshot): void {
     lastKnownSnapshots.delete(statePath);
     return;
   }
+  let contentFingerprint: string;
+  try {
+    contentFingerprint = fingerprintSnapshotContent(
+      fs.readFileSync(statePath, 'utf8'),
+    );
+  } catch {
+    lastKnownSnapshots.delete(statePath);
+    return;
+  }
   if (
     !lastKnownSnapshots.has(statePath) &&
     lastKnownSnapshots.size >= LAST_KNOWN_SNAPSHOTS_MAX
@@ -314,7 +330,11 @@ function rememberSnapshot(statePath: string, snapshot: TuiSnapshot): void {
     const oldest = lastKnownSnapshots.keys().next().value;
     if (oldest !== undefined) lastKnownSnapshots.delete(oldest);
   }
-  lastKnownSnapshots.set(statePath, { snapshot, ...stat });
+  lastKnownSnapshots.set(statePath, {
+    snapshot,
+    ...stat,
+    contentFingerprint,
+  });
 }
 
 function memoFor(statePath: string): TuiSnapshot | undefined {
@@ -324,13 +344,23 @@ function memoFor(statePath: string): TuiSnapshot | undefined {
   if (
     !stat ||
     stat.ino !== entry.ino ||
-    stat.mtimeMs !== entry.mtimeMs ||
-    stat.ctimeMs !== entry.ctimeMs ||
+    stat.mtimeNs !== entry.mtimeNs ||
+    stat.ctimeNs !== entry.ctimeNs ||
     stat.size !== entry.size
   ) {
     lastKnownSnapshots.delete(statePath);
     return undefined;
   }
+  try {
+    const content = fs.readFileSync(statePath, 'utf8');
+    if (fingerprintSnapshotContent(content) !== entry.contentFingerprint) {
+      lastKnownSnapshots.delete(statePath);
+      return undefined;
+    }
+  } catch {
+    lastKnownSnapshots.delete(statePath);
+    return undefined;
+  }
   return entry.snapshot;
 }