diff --git a/alias.ts b/alias.ts index 839d87a0..70c63c3b 100644 --- a/alias.ts +++ b/alias.ts @@ -5,6 +5,7 @@ import { join, relative } from 'pathe' const root = fileURLToPath(new URL('.', import.meta.url)) const r = (path: string) => fileURLToPath(new URL(`./packages/${path}`, import.meta.url)) const p = (path: string) => fileURLToPath(new URL(`./plugins/${path}`, import.meta.url)) +const s = (path: string) => fileURLToPath(new URL(`./services/${path}`, import.meta.url)) export const alias = { 'devframe/rpc/transports/sse-client': r('devframe/src/rpc/transports/sse-client.ts'), @@ -131,6 +132,8 @@ export const alias = { '@devframes/plugin-assets/cli': p('assets/src/cli.ts'), '@devframes/plugin-assets/vite': p('assets/src/vite.ts'), '@devframes/plugin-assets': p('assets/src/index.ts'), + '@devframes/service-open': s('open/src/index.ts'), + '@devframes/service-shiki': s('shiki/src/index.ts'), } // update tsconfig.base.json - CSS aliases exist for Vite resolution only; diff --git a/docs/errors/DF0066.md b/docs/errors/DF0066.md index 38d4049f..e965bbee 100644 --- a/docs/errors/DF0066.md +++ b/docs/errors/DF0066.md @@ -10,29 +10,27 @@ outline: deep ## Cause -Wire services are deduplicated by npm package name: the first installation wins, and later installs of the same package return the existing node API. Option sets from multiple installers only merge **before** the `ctx.services.ready()` barrier fires — an install that arrives after the service was constructed can no longer influence its configuration, so any options it carried are dropped with this warning. +Wire services are deduplicated by npm package name: the first installation wins, and later installs of the same package return the existing node API. Declared services are constructed once **before setup runs**, deep-merging every declarer's options. Calling `ctx.services.install()` for an already-constructed package — the dynamic escape hatch used after that point — can no longer influence its configuration, so any options it carries are dropped with this warning. ## Example ```ts -await ctx.services.ready() - -// ✗ The service is already constructed; { themes } is ignored. -await ctx.services.install(createShikiService({ themes })) +// The package is already declared (and constructed pre-setup) elsewhere. +// ✗ This late install can't merge; { themes } is ignored. +ctx.services.install(createShikiService({ themes })) ``` ## Fix -Install the service (or declare it in `DevframeDefinition.services`) before the barrier — a host's explicit installs during setup/`configure` naturally run before the adapter fires `ready()`, so its options join the merge: +Declare the service so its options join the pre-setup merge — on the plugin's `DevframeDefinition.services`, or host-wide via `initHub({ services })`: ```ts -await initHub({ - async configure(ctx) { - ctx.services.install(createShikiService({ themes })) // ✓ merges - }, +initHub({ + services: [createShikiService({ themes })], // ✓ merges before setup + devframes: [/* … */], }) ``` ## Source -- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — `install()`/the barrier flush warn when an already-installed package is installed again. +- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — `installPackage` warns when an already-installed package is installed again. diff --git a/docs/errors/DF0067.md b/docs/errors/DF0067.md index 15656090..3f3d5e55 100644 --- a/docs/errors/DF0067.md +++ b/docs/errors/DF0067.md @@ -10,7 +10,7 @@ outline: deep ## Cause -A service descriptor marked `required: true` names a package that could not be resolved and imported at the `ctx.services.ready()` barrier. Descriptors resolve against the declaring plugin's own dependencies first (then the workspace root), so this usually means the service package is missing from the declarer's `dependencies`, or isn't installed. +A service descriptor marked `required: true` names a package that could not be resolved and imported when services are constructed before setup. Descriptors resolve against the declaring plugin's own dependencies first (then the workspace root), so this usually means the service package is missing from the declarer's `dependencies`, or isn't installed. Descriptors without `required` degrade instead: the missing service is skipped and clients observe `services.has(pkg) === false`. @@ -19,7 +19,7 @@ Descriptors without `required` degrade instead: the missing service is skipped a ```ts defineDevframe({ services: [ - // ✗ Throws at the ready() barrier when the package isn't installed. + // ✗ Throws before setup when the package isn't installed. { package: '@devframes/service-shiki', required: true }, ], }) @@ -31,4 +31,4 @@ Install the service package next to whoever declares it — a plugin declaring i ## Source -- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — the barrier flush throws when a `required` descriptor's package fails to import. +- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — the pre-setup construction throws when a `required` descriptor's package fails to import. diff --git a/docs/errors/DF0068.md b/docs/errors/DF0068.md index 0e820e22..fbd503be 100644 --- a/docs/errors/DF0068.md +++ b/docs/errors/DF0068.md @@ -10,7 +10,7 @@ outline: deep ## Cause -A service descriptor marked `required: true` declares a `version` range, and the version of the service that actually resolved falls outside it. The range is checked at the `ctx.services.ready()` barrier against the resolved definition's own `version`. +A service descriptor marked `required: true` declares a `version` range, and the version of the service that actually resolved falls outside it. The range is checked when services are constructed before setup against the resolved definition's own `version`. Without `required`, the same mismatch installs the service anyway and warns with [`DF0069`](/errors/DF0069). @@ -31,4 +31,4 @@ Align the installed service package with the declared range (update whichever si ## Source -- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — the barrier flush checks each descriptor's `version` range against the resolved definition. +- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — the pre-setup construction checks each descriptor's `version` range against the resolved definition. diff --git a/docs/errors/DF0069.md b/docs/errors/DF0069.md index aeb8ef6a..b7b283b7 100644 --- a/docs/errors/DF0069.md +++ b/docs/errors/DF0069.md @@ -31,4 +31,4 @@ Align the installed service package with the declared range to silence the warni ## Source -- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — the barrier flush checks each descriptor's `version` range against the resolved definition. +- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — the pre-setup construction checks each descriptor's `version` range against the resolved definition. diff --git a/docs/errors/DF0070.md b/docs/errors/DF0070.md index f90441c8..19eb611a 100644 --- a/docs/errors/DF0070.md +++ b/docs/errors/DF0070.md @@ -33,4 +33,4 @@ A service package's default export must be its `createService` factory, retur ## Source -- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — `install()` validates its input; the barrier flush validates imported factories and the definitions they return. +- [`packages/devframe/src/node/host-services.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/host-services.ts) — `install()` validates its input; the pre-setup construction validates imported factories and the definitions they return. diff --git a/docs/errors/DF0071.md b/docs/errors/DF0071.md deleted file mode 100644 index 847fb9d0..00000000 --- a/docs/errors/DF0071.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -outline: deep ---- - -# DF0071: Deferred Service Installation Failed On Connect - -## Message - -> Deferred service installation failed while flushing on the first client connection: `{reason}` - -## Cause - -Queued wire-service installs are normally flushed by the host calling `ctx.services.ready()` once every devframe's setup has run — the first-party adapters (`initDevframe`, `createBuild`, `createCac`, `initHub`) all do. As a safety net, a host that never calls it still gets the flush right before the first client RPC connection is served. When that deferred flush fails (a `required` service missing, an unsatisfied version range, a throwing `setup`), the error can only be reported — a connection hook is no place to crash — so it surfaces as this diagnostic instead of a startup failure. - -## Fix - -Call `ctx.services.ready()` explicitly after every devframe's setup has run, so installation errors throw at startup where they can be acted on: - -```ts -await devframe.setup(ctx) -await ctx.services.ready() -``` - -The `reason` carries the underlying error (typically [`DF0067`](/errors/DF0067), [`DF0068`](/errors/DF0068), or a service `setup` failure) — fix that root cause as its own page describes. - -## Source - -- [`packages/devframe/src/node/rpc-core.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/node/rpc-core.ts) — `createContextRpcServer()`'s connect hook reports a failing deferred flush. diff --git a/docs/guide/services.md b/docs/guide/services.md index 2f698975..3bd5c18c 100644 --- a/docs/guide/services.md +++ b/docs/guide/services.md @@ -101,15 +101,12 @@ export default function createOpenService(options?: OpenServiceOptions): Devfram Two declaration merges make it fully typed for consumers: the fully-qualified RPC ids go into `DevframeRpcServerFunctions`, and the package → scope mapping into `DevframeServicesScopeRegistry` (so a client's `services.get()` returns a scoped, typed RPC handle). -### Installing +### Declaring -A host with the factory at hand installs explicitly; a plugin declares what it consumes on its definition and the adapter resolves the package **against the plugin's own dependencies** — the base for that resolution is the definition's [`importMetaUrl`](./devframe-definition#resolving-against-the-plugins-own-dependencies), so a plugin ships a service package as its own dependency and users install nothing extra: +Services are **declarative**. A plugin lists what it consumes on its definition; a host lists shared ones on `initHub`. The adapter resolves each package — for a plugin, **against the plugin's own dependencies** via the definition's [`importMetaUrl`](./devframe-definition#resolving-against-the-plugins-own-dependencies), so a plugin ships a service package as its own dependency and users install nothing extra — and constructs it: ```ts -// host side (e.g. inside initHub's configure) -ctx.services.install(createShikiService({ themes })) - -// plugin side — declarative +// plugin side — on the definition defineDevframe({ importMetaUrl: import.meta.url, // resolution base for the declared packages services: [ @@ -117,14 +114,24 @@ defineDevframe({ { package: '@devframes/service-shiki', version: '^1', options: { langs: ['vue'] } }, ], }) + +// host side — shared services on initHub +initHub({ + services: [createShikiService({ themes })], + devframes: [/* … */], +}) ``` Entries are optional by default — a package that isn't installed is skipped and clients see `has() === false`. Mark an entry `required: true` to fail hard instead ([`DF0067`](https://devfra.me/errors/DF0067) on a missing package, [`DF0068`](https://devfra.me/errors/DF0068) on an unsatisfied `version` range; without it a range mismatch only warns with [`DF0069`](https://devfra.me/errors/DF0069)). -Installs queue until the adapter fires the `ctx.services.ready()` barrier after every devframe's setup has run. There each service is constructed **once**, with the option sets from every declarer merged — through the definition's `mergeOptions` when it declares one, otherwise shallow-merged in declaration order, so a host installing last wins. After the barrier, installing an already-installed package returns the existing API and warns ([`DF0066`](https://devfra.me/errors/DF0066)) when its options had to be ignored. +### Lifecycle: ready before setup + +Services are constructed and made ready **before any `setup(ctx)` runs**. The hub collects every declared service (across all devframes plus `initHub`), constructs each **once** — deep-merging the option sets from every declarer (objects recurse, arrays union-dedupe, scalars take the later value; a service may override with its own `mergeOptions`) — and only then runs the setups. So `setup(ctx)` can consume a service synchronously via `ctx.services.get(pkg)`, including one another devframe declared. Server-side consumers get the node API from the same registry — `ctx.services.get('@devframes/service-open')` or `whenAvailable` — with no RPC hop. +Declarative covers the common case. For a service whose configuration is only known at runtime, `ctx.services.install(input)` is the dynamic escape hatch: after the pre-setup construction it builds immediately; re-installing an already-constructed package returns the existing API and warns ([`DF0066`](https://devfra.me/errors/DF0066)) if it carried options that can no longer merge. + ### Feature-detecting on the client Installed services are advertised through the `devframe:services` [shared state](./shared-state); the client mirrors it on `rpc.services`: @@ -144,6 +151,12 @@ state.on('updated', render) `has()`/`get()`/`keys()` are synchronous snapshots of the advertisement — before the first sync lands they read as empty, and `get()` returns `undefined` rather than throwing, so the natural shape of consuming code is "render the fallback until the service appears". Each handle carries the advertised `version` and `meta` for finer gating. +### Built-in services + +**`@devframes/service-open`** (`devframes:service:open`) opens files in the user's editor (`open-in-editor`, with optional `line`/`column`) or reveals them in the OS file explorer (`open-in-finder`). Paths may be absolute or relative to the workspace root (so a client with only a workspace-relative path — a message's file position, say — calls it directly); the service refuses anything outside the workspace root and the configured extra `roots` (`DS_OPEN_0002`), and gates editor commands to the `KNOWN_EDITORS` picklist. Options: `{ editor?, roots? }` — the preferred editor (later installer wins) and additional openable directories (merged as a union). It supersedes the per-plugin `devframe/recipes/common-rpc-functions` registrations, now deprecated. + +**`@devframes/service-shiki`** (`devframes:service:shiki`) renders [Shiki](https://shiki.style) syntax highlighting on the server, so plugin bundles stop shipping grammars and themes. Three RPC queries — `highlight` (dual-theme HTML), `code-to-hast`, and `code-to-tokens` (for renderers that own their DOM, e.g. diff views) — all client-`cacheable` and LRU-cached server-side per `(code, lang, themes)`. Unknown languages degrade to plain text. Options: `{ themes?, langs? }` — the default light/dark pair (defaults `vitesse-light`/`vitesse-dark`, matching the design system; later installer wins) and languages to eagerly load (merged as a union). + ## Services, RPC, or shared state? Each mechanism covers a different direction of travel: diff --git a/docs/helpers/common-rpc-functions.md b/docs/helpers/common-rpc-functions.md index f9ce70d4..8aa5f97b 100644 --- a/docs/helpers/common-rpc-functions.md +++ b/docs/helpers/common-rpc-functions.md @@ -4,6 +4,9 @@ outline: deep # Common RPC Functions +> [!WARNING] +> Deprecated in favor of the [`@devframes/service-open` wire service](/guide/services#built-in-services) — one host-level installation shared by every plugin, feature-detectable from clients, with workspace-root path containment on top of the editor gating. The recipe keeps working; removal lands in a future major. + Prebuilt RPC actions for the two file-system actions every CLI devtool needs — opening a file in the editor, revealing a path in the OS file explorer. Use the recipe instead of re-implementing them so every devframe converges on the same registered names and payload shape. ```ts diff --git a/docs/plugins/assets.md b/docs/plugins/assets.md index 831c8e46..d20339f0 100644 --- a/docs/plugins/assets.md +++ b/docs/plugins/assets.md @@ -95,12 +95,12 @@ All functions are namespaced `devframes:plugin:assets:*`: | `list` | `query`, `snapshot: true` | Every file under the managed directory, with type, size, and last-modified time. | | `capabilities` | `query`, `snapshot: true` | Whether write actions are enabled, and the upload allow-list — lets the UI gate itself proactively. | | `read-image-meta` | `query` | Width, height, and orientation for an image asset. | -| `read-text` | `query` | Truncated text content, for preview. | +| `read-text` | `query` | Truncated text content, for preview. When the host advertises the [`@devframes/service-shiki` wire service](/guide/services#built-in-services), the panel renders it server-highlighted; otherwise it falls back to a plain `
`. |
 | `upload` | `action` | Allocates a streaming upload slot; the client pipes the file's bytes over the paired channel. |
 | `rename` | `action` | Renames an asset within its folder, preserving its extension. |
 | `delete` | `action` | Deletes one or more assets in a single call. |
 | `mkdir` | `action` | Creates a folder, including missing parents. |
-| `open-in-editor` / `reveal-in-folder` | `action` | Launch the asset in your editor, or reveal its containing folder in the OS file manager. Always registered, regardless of `write`. |
+| `open-in-editor` / `reveal-in-folder` | `action` | Launch the asset in your editor, or reveal its containing folder in the OS file manager, delegating to the [`@devframes/service-open` wire service](/guide/services#built-in-services) (installed by the plugin with the managed dir as an allowed root). Always registered, regardless of `write`. |
 
 `upload` / `rename` / `delete` / `mkdir` are registered only when `write` is enabled.
 
diff --git a/knip.jsonc b/knip.jsonc
index 0b1f6a81..2ddea837 100644
--- a/knip.jsonc
+++ b/knip.jsonc
@@ -16,9 +16,11 @@
   // default export is its `createDevframe` factory") - `export default
   // createXDevframe` right below `export function createXDevframe(...) {}`.
   // Deliberate, not an accidental duplicate: the named export is for options,
-  // the default export is the conventional single-devframe import.
+  // the default export is the conventional single-devframe import. Service
+  // packages (`createService`) follow the same rule.
   "ignoreIssues": {
-    "plugins/*/src/index.ts": ["duplicates"]
+    "plugins/*/src/index.ts": ["duplicates"],
+    "services/*/src/index.ts": ["duplicates"]
   },
   "workspaces": {
     ".": {
diff --git a/packages/devframe/src/adapters/build.ts b/packages/devframe/src/adapters/build.ts
index 5f3aaa4f..047f5c58 100644
--- a/packages/devframe/src/adapters/build.ts
+++ b/packages/devframe/src/adapters/build.ts
@@ -89,10 +89,11 @@ export async function createBuild(d: DevframeDefinition, options: CreateBuildOpt
     host,
     importMetaUrl: d.importMetaUrl,
   })
+  // Services ready before setup, so setup can consume them synchronously.
   for (const input of d.services ?? [])
     void ctx.services.install(input, { resolveFrom: d.importMetaUrl })
-  await d.setup(ctx)
   await ctx.services.ready()
+  await d.setup(ctx)
 
   await fs.mkdir(resolve(outDir, DEVFRAME_RPC_DUMP_DIRNAME), { recursive: true })
 
diff --git a/packages/devframe/src/adapters/embedded.ts b/packages/devframe/src/adapters/embedded.ts
index 3edfad3c..cc6275a6 100644
--- a/packages/devframe/src/adapters/embedded.ts
+++ b/packages/devframe/src/adapters/embedded.ts
@@ -16,10 +16,11 @@ export interface CreateEmbeddedOptions {
  * effective default follows the hosted rule of `def.basePath ?? '/__/'`.
  */
 export async function createEmbedded(d: DevframeDefinition, options: CreateEmbeddedOptions): Promise {
-  // Declarative services queue before setup; the owning host fires the
-  // `ctx.services.ready()` barrier (post-barrier registration installs
-  // immediately).
+  // Services ready before setup. `ready()` is idempotent: on an
+  // already-running host it's a no-op and the fresh installs construct
+  // immediately; on a not-yet-started one it fires the initial barrier.
   for (const input of d.services ?? [])
     void options.ctx.services.install(input, { resolveFrom: d.importMetaUrl })
+  await options.ctx.services.ready()
   await d.setup(options.ctx)
 }
diff --git a/packages/devframe/src/adapters/initiate.ts b/packages/devframe/src/adapters/initiate.ts
index bdb88369..802aebc9 100644
--- a/packages/devframe/src/adapters/initiate.ts
+++ b/packages/devframe/src/adapters/initiate.ts
@@ -290,12 +290,12 @@ export function initDevframe(
         importMetaUrl: def.importMetaUrl,
       })
       const setupInfo: DevframeSetupInfo = { flags: options.flags ?? {} }
-      // Declarative services queue ahead of setup (their promises resolve at
-      // the ready() barrier below), resolving against the plugin's own deps.
+      // Wire services are constructed and made ready BEFORE setup, so
+      // `setup(ctx)` can consume them synchronously (`ctx.services.get`).
       for (const input of def.services ?? [])
         void context.services.install(input, { resolveFrom: def.importMetaUrl })
-      await def.setup(context, setupInfo)
       await context.services.ready()
+      await def.setup(context, setupInfo)
 
       // Route-based MCP server (opt-in). Mounted before the SPA static
       // catch-all so the exact `__mcp` route wins, and advertised in
diff --git a/packages/devframe/src/adapters/mcp/build-server.ts b/packages/devframe/src/adapters/mcp/build-server.ts
index 75544837..69f26a39 100644
--- a/packages/devframe/src/adapters/mcp/build-server.ts
+++ b/packages/devframe/src/adapters/mcp/build-server.ts
@@ -118,10 +118,11 @@ export async function createMcpServer(
     host,
     importMetaUrl: definition.importMetaUrl,
   })
+  // Services ready before setup, so setup can consume them synchronously.
   for (const input of definition.services ?? [])
     void ctx.services.install(input, { resolveFrom: definition.importMetaUrl })
-  await definition.setup(ctx)
   await ctx.services.ready()
+  await definition.setup(ctx)
 
   const { server, dispose } = buildMcpServerFromContext(ctx, {
     serverName: options.serverName ?? `${definition.id} (devframe)`,
diff --git a/packages/devframe/src/node/__tests__/services-install.test.ts b/packages/devframe/src/node/__tests__/services-install.test.ts
index d1e70742..3d32c7ef 100644
--- a/packages/devframe/src/node/__tests__/services-install.test.ts
+++ b/packages/devframe/src/node/__tests__/services-install.test.ts
@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import { satisfiesVersionRange, shallowMergeOptionSets } from '../services-install'
+import { deepMergeOptionSets, satisfiesVersionRange } from '../services-install'
 
 describe('satisfiesVersionRange', () => {
   it('matches exact versions', () => {
@@ -57,13 +57,23 @@ describe('satisfiesVersionRange', () => {
   })
 })
 
-describe('shallowMergeOptionSets', () => {
-  it('merges plain objects in order, later wins', () => {
-    expect(shallowMergeOptionSets([{ a: 1, b: 1 }, { b: 2, c: 3 }])).toEqual({ a: 1, b: 2, c: 3 })
+describe('deepMergeOptionSets', () => {
+  it('merges plain objects in order, later scalars win', () => {
+    expect(deepMergeOptionSets([{ a: 1, b: 1 }, { b: 2, c: 3 }])).toEqual({ a: 1, b: 2, c: 3 })
   })
 
-  it('collapses to last-wins when a set is not a plain object', () => {
-    expect(shallowMergeOptionSets([{ a: 1 }, ['x']])).toEqual(['x'])
-    expect(shallowMergeOptionSets(['x', { a: 1 }])).toEqual({ a: 1 })
+  it('recurses into nested objects', () => {
+    expect(deepMergeOptionSets([{ nested: { x: 1, y: 1 } }, { nested: { y: 2, z: 3 } }]))
+      .toEqual({ nested: { x: 1, y: 2, z: 3 } })
+  })
+
+  it('unions + dedupes arrays', () => {
+    expect(deepMergeOptionSets([{ roots: ['a', 'b'] }, { roots: ['b', 'c'] }]))
+      .toEqual({ roots: ['a', 'b', 'c'] })
+  })
+
+  it('takes the later value for mismatched shapes', () => {
+    expect(deepMergeOptionSets([{ a: 1 }, ['x']])).toEqual(['x'])
+    expect(deepMergeOptionSets(['x', { a: 1 }])).toEqual({ a: 1 })
   })
 })
diff --git a/packages/devframe/src/node/__tests__/services.test.ts b/packages/devframe/src/node/__tests__/services.test.ts
index af45b662..3221835e 100644
--- a/packages/devframe/src/node/__tests__/services.test.ts
+++ b/packages/devframe/src/node/__tests__/services.test.ts
@@ -121,8 +121,8 @@ function writeFakeServicePackage(dir: string, name: string, version: string): vo
   ].join('\n'))
 }
 
-describe('wire services (install / ready barrier)', () => {
-  it('queues installs and constructs once at the barrier with merged options', async () => {
+describe('wire services (install / ready)', () => {
+  it('queues installs and constructs once at ready() with merged options', async () => {
     const { ctx } = await createCtx()
     const setup = vi.fn((_ctx: unknown, info: { options?: any }) => ({ options: info.options }))
     const def = defineTestService({ setup, options: { a: 1, b: 1 } })
@@ -131,17 +131,28 @@ describe('wire services (install / ready barrier)', () => {
     // A second install of the same package contributes its options to the merge.
     const second = ctx.services.install({ package: '@test/svc', options: { b: 2, c: 3 } })
 
+    // The service's own setup runs at ready(), not before.
     expect(setup).not.toHaveBeenCalled()
     await ctx.services.ready()
 
     expect(setup).toHaveBeenCalledTimes(1)
-    // Shallow merge in declaration order — later sets win.
+    // Deep merge in declaration order — later scalars win.
     await expect(first).resolves.toEqual({ options: { a: 1, b: 2, c: 3 } })
     await expect(second).resolves.toEqual({ options: { a: 1, b: 2, c: 3 } })
     // The node API is provided under the package name.
     expect(ctx.services.get('@test/svc')).toEqual({ options: { a: 1, b: 2, c: 3 } })
   })
 
+  it('deep-merges option sets: arrays union, nested objects recurse', async () => {
+    const { ctx } = await createCtx()
+    void ctx.services.install(defineTestService({ options: { roots: ['a'], nested: { x: 1 } } }))
+    void ctx.services.install({ package: '@test/svc', options: { roots: ['b', 'a'], nested: { y: 2 } } })
+    await ctx.services.ready()
+    expect(ctx.services.get('@test/svc')).toEqual({
+      options: { roots: ['a', 'b'], nested: { x: 1, y: 2 } },
+    })
+  })
+
   it('uses the definition mergeOptions when declared', async () => {
     const { ctx } = await createCtx()
     const def = defineTestService({
@@ -164,7 +175,7 @@ describe('wire services (install / ready barrier)', () => {
     })
   })
 
-  it('creates an empty advertisement state at the barrier when nothing installs', async () => {
+  it('creates an empty advertisement state at ready() when nothing installs', async () => {
     const { ctx } = await createCtx()
     await ctx.services.ready()
     expect(ctx.rpc.sharedState.keys()).toContain(DEVFRAME_SERVICES_STATE_KEY)
@@ -182,7 +193,7 @@ describe('wire services (install / ready barrier)', () => {
     await expect((ctx.rpc.invokeLocal as (method: string) => Promise)('test:svc:hello')).resolves.toBe('hi')
   })
 
-  it('post-barrier installs construct immediately; duplicates warn and return the first API', async () => {
+  it('post-ready installs construct immediately; duplicates warn and return the first API', async () => {
     const { ctx } = await createCtx()
     await ctx.services.ready()
     const api = await ctx.services.install(defineTestService({ options: { a: 1 } }))
diff --git a/packages/devframe/src/node/diagnostics.ts b/packages/devframe/src/node/diagnostics.ts
index a6457538..70c15d7e 100644
--- a/packages/devframe/src/node/diagnostics.ts
+++ b/packages/devframe/src/node/diagnostics.ts
@@ -194,10 +194,5 @@ export const diagnostics = defineDiagnostics({
         `Invalid service "${p.package}": ${p.reason}`,
       fix: 'A service package\'s default export must be a factory returning a `DevframeServiceDefinition` — an object with `package`, `version`, `scope`, and a `setup` function.',
     },
-    DF0071: {
-      why: (p: { reason: string }) =>
-        `Deferred service installation failed while flushing on the first client connection: ${p.reason}`,
-      fix: 'Call `ctx.services.ready()` explicitly after every devframe\'s setup has run (the first-party adapters do) so installation errors surface at startup instead of at connect time.',
-    },
   },
 })
diff --git a/packages/devframe/src/node/host-services.ts b/packages/devframe/src/node/host-services.ts
index a5e4f98d..a565e31a 100644
--- a/packages/devframe/src/node/host-services.ts
+++ b/packages/devframe/src/node/host-services.ts
@@ -12,7 +12,7 @@ import process from 'node:process'
 import { DEVFRAME_SERVICES_STATE_KEY } from 'devframe/constants'
 import { createDebug } from 'obug'
 import { diagnostics } from './diagnostics'
-import { expandResolveFrom, importServicePackage, satisfiesVersionRange, shallowMergeOptionSets } from './services-install'
+import { deepMergeOptionSets, expandResolveFrom, importServicePackage, satisfiesVersionRange } from './services-install'
 
 const debug = createDebug('devframe:services')
 
@@ -225,15 +225,16 @@ export class DevframeServicesHostImpl implements DevframeServicesHost {
       diagnostics.DF0069({ package: pkg, required: descriptor.version, installed: def.version })
     }
 
-    // Merge every installer's option set in declaration order (later wins on
-    // the default shallow merge, so a host installing last takes precedence).
+    // Merge every installer's option set in declaration order (the default
+    // deep-merge unions arrays and lets later scalars win; a service may
+    // override with its own `mergeOptions`).
     const sets = entries
       .map(entry => entry.input.options)
       .filter(options => options !== undefined)
     const options = def.mergeOptions
       ? def.mergeOptions(sets)
       : sets.length > 0
-        ? shallowMergeOptionSets(sets)
+        ? deepMergeOptionSets(sets)
         : undefined
 
     if (!this.context)
diff --git a/packages/devframe/src/node/rpc-core.ts b/packages/devframe/src/node/rpc-core.ts
index 93006d30..a2a5d6b6 100644
--- a/packages/devframe/src/node/rpc-core.ts
+++ b/packages/devframe/src/node/rpc-core.ts
@@ -130,25 +130,16 @@ export function createContextRpcServer(options: CreateContextRpcServerOptions):
     })
   }
 
-  const onConnected = (connection: DevframeRpcConnection, meta: DevframeNodeRpcSessionMeta): void => {
-    // Safety net for the services collect-then-setup barrier: a host that
-    // never called `ctx.services.ready()` still flushes deferred service
-    // installs before the first client is served. Idempotent and cheap once
-    // fired; failures are reported (not thrown) since a connect hook is no
-    // place to crash — adapters that `await ready()` surface them at startup.
-    void Promise.resolve()
-      .then(() => context.services.ready?.())
-      .catch((error) => {
-        const reason = error instanceof Error ? error.message : String(error)
-        diagnostics.DF0071({ reason, cause: error }, { method: 'error' })
-      })
-    const session: DevframeNodeRpcSession = {
-      meta,
-      rpc: rpcGroup.clients.find(client => (client as any).$meta === meta) as any,
-    }
-    authHandler?.onConnect(connection, session)
-    options.onPeerConnect?.(connection, session)
-  }
+  const onConnected = (authHandler || options.onPeerConnect)
+    ? (connection: DevframeRpcConnection, meta: DevframeNodeRpcSessionMeta): void => {
+        const session: DevframeNodeRpcSession = {
+          meta,
+          rpc: rpcGroup.clients.find(client => (client as any).$meta === meta) as any,
+        }
+        authHandler?.onConnect(connection, session)
+        options.onPeerConnect?.(connection, session)
+      }
+    : undefined
 
   const onDisconnected = (connection: DevframeRpcConnection, meta: DevframeNodeRpcSessionMeta): void => {
     options.onPeerDisconnect?.(connection, meta)
diff --git a/packages/devframe/src/node/services-install.ts b/packages/devframe/src/node/services-install.ts
index d5ea6ed9..dd859b72 100644
--- a/packages/devframe/src/node/services-install.ts
+++ b/packages/devframe/src/node/services-install.ts
@@ -180,13 +180,31 @@ export function satisfiesVersionRange(version: string, range: string): boolean {
   )
 }
 
+function isPlainObject(value: unknown): value is Record {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+/** Deep-merge two values with the service option-set rules (see below). */
+function deepMergeTwo(a: unknown, b: unknown): unknown {
+  // Arrays union-dedupe, so multiple installers' `roots` / `langs` accumulate.
+  if (Array.isArray(a) && Array.isArray(b))
+    return [...new Set([...a, ...b])]
+  if (isPlainObject(a) && isPlainObject(b)) {
+    const out: Record = { ...a }
+    for (const key of Object.keys(b))
+      out[key] = key in a ? deepMergeTwo(a[key], b[key]) : b[key]
+    return out
+  }
+  // Scalars / mismatched shapes: later set wins (e.g. `themes` per key).
+  return b
+}
+
 /**
  * Default option-set merge when a service declares no `mergeOptions`:
- * shallow-merge plain objects in declaration order (later sets win); any
- * non-object set collapses the merge to "last one wins".
+ * deep-merge in declaration order — objects recurse, arrays union-dedupe,
+ * scalars take the later value. Covers the built-in services (`roots` /
+ * `langs` union, `themes` per-key last-wins) without a custom hook.
  */
-export function shallowMergeOptionSets(sets: Options[]): Options {
-  if (sets.some(set => typeof set !== 'object' || set === null || Array.isArray(set)))
-    return sets[sets.length - 1] as Options
-  return Object.assign({}, ...sets) as Options
+export function deepMergeOptionSets(sets: Options[]): Options {
+  return sets.reduce((merged, set) => deepMergeTwo(merged, set) as Options)
 }
diff --git a/packages/devframe/src/recipes/common-rpc-functions.ts b/packages/devframe/src/recipes/common-rpc-functions.ts
index 60f8b9fd..87b5ce62 100644
--- a/packages/devframe/src/recipes/common-rpc-functions.ts
+++ b/packages/devframe/src/recipes/common-rpc-functions.ts
@@ -98,6 +98,10 @@ export const KNOWN_EDITORS: KnownEditor[] = [
  *   },
  * })
  * ```
+ *
+ * @deprecated Use the `@devframes/service-open` wire service instead — one
+ * host-level installation shared by every plugin and feature-detectable from
+ * clients, with workspace-root path containment on top of the editor gating.
  */
 export const openInEditor = defineRpcFunction({
   name: 'devframe:open-in-editor',
@@ -121,6 +125,10 @@ export const openInEditor = defineRpcFunction({
  *
  * ctx.rpc.register(openInFinder)
  * ```
+ *
+ * @deprecated Use the `@devframes/service-open` wire service instead — one
+ * host-level installation shared by every plugin and feature-detectable from
+ * clients, with workspace-root path containment.
  */
 export const openInFinder = defineRpcFunction({
   name: 'devframe:open-in-finder',
@@ -143,5 +151,7 @@ export const openInFinder = defineRpcFunction({
  *
  * commonRpcFunctions.forEach(fn => ctx.rpc.register(fn))
  * ```
+ *
+ * @deprecated Use the `@devframes/service-open` wire service instead.
  */
 export const commonRpcFunctions = [openInEditor, openInFinder] as const
diff --git a/packages/devframe/src/types/services.ts b/packages/devframe/src/types/services.ts
index 2e009a51..053d9301 100644
--- a/packages/devframe/src/types/services.ts
+++ b/packages/devframe/src/types/services.ts
@@ -210,15 +210,19 @@ export interface DevframeServicesHost {
   /** Ids of every currently-provided service. */
   keys: () => string[]
   /**
-   * Install a **wire service** (see {@link DevframeServiceDefinition}).
-   * Before {@link DevframeServicesHost.ready} fires, installs are queued —
-   * option sets from every installer accumulate and each service is
-   * constructed **once** at the barrier with the merged options; the
-   * returned promise resolves with the service's node API then (or
-   * `undefined` when an optional descriptor's package can't be imported).
-   * After the barrier, installs construct immediately; installing an
-   * already-installed package returns the existing API (a warning is
-   * emitted when the late install carried options, since they're ignored).
+   * Install a **wire service** (see {@link DevframeServiceDefinition}). The
+   * common path is declarative — list services on `DevframeDefinition.services`
+   * (or `initHub({ services })`) and the adapter installs them for you before
+   * `setup` runs. Call `install()` directly only for the dynamic escape hatch:
+   * a service configured at runtime from data unknown until then.
+   *
+   * Before the pre-setup ready fires, installs are queued and their option
+   * sets deep-merged, constructing each service **once**. After it, an install
+   * constructs immediately; installing an already-installed package returns
+   * the existing API (a warning — `DF0066` — when the late install carried
+   * options, since they're ignored). The returned promise resolves with the
+   * node API (or `undefined` when an optional descriptor's package can't be
+   * imported).
    *
    * `resolveFrom` is where a descriptor's package resolves **from**: a path
    * or file URL (e.g. the declaring devframe's `importMetaUrl`), or an npm
@@ -230,13 +234,14 @@ export interface DevframeServicesHost {
     options?: { resolveFrom?: string | null },
   ) => Promise
   /**
-   * Fire the collect-then-setup barrier: resolve every queued descriptor
-   * (importing its package), merge option sets per service, construct each
-   * service once, `provide()` its node API under the package name, and
-   * advertise it to clients via the `devframe:services` shared state.
-   * Idempotent — adapters call it once after every devframe's `setup` has
-   * run; a repeat returns the same promise. Rejects when a `required`
-   * service fails to import or misses its version range.
+   * Construct every queued service — importing descriptor packages, merging
+   * option sets, `provide()`-ing each node API under its package name, and
+   * advertising it to clients via the `devframe:services` shared state.
+   * Idempotent. **Internal**: the adapters call it once, before running any
+   * `setup`, so services are ready for `setup` to consume; application code
+   * uses declarative `services` (or `install()` for the dynamic case) and
+   * never calls this. Rejects when a `required` service fails to import or
+   * misses its version range.
    */
   ready: () => Promise
 }
diff --git a/packages/hub/src/node/__tests__/install-devframe.test.ts b/packages/hub/src/node/__tests__/install-devframe.test.ts
index 950c0d54..0fa31ec7 100644
--- a/packages/hub/src/node/__tests__/install-devframe.test.ts
+++ b/packages/hub/src/node/__tests__/install-devframe.test.ts
@@ -19,6 +19,12 @@ function createContext(): DevframeHubContext {
     views: {
       hostStatic: () => {},
     },
+    // Minimal stub — these tests drive dock/setup wiring, not the services
+    // lifecycle (the demo devframe declares none).
+    services: {
+      install: () => Promise.resolve(undefined),
+      ready: () => Promise.resolve(),
+    },
   } as unknown as DevframeHubContext
   context.docks = new DevframeDocksHost(context)
   // `createHubContext` wires this; the hand-built fake context here does the
diff --git a/packages/hub/src/node/initiate.ts b/packages/hub/src/node/initiate.ts
index f2bcea27..a27b7dec 100644
--- a/packages/hub/src/node/initiate.ts
+++ b/packages/hub/src/node/initiate.ts
@@ -1,7 +1,7 @@
 import type { DevframeInstanceRecord } from 'devframe/internal'
 import type { DevframeAuthHandler } from 'devframe/node/auth'
 import type { WsOriginRegistry } from 'devframe/rpc/transports/ws-server'
-import type { ConnectionMeta, DevframeDefinition, DevframeSseOptions, DevframeStorageScope, DevframeWsOptions, McpRouteOptions } from 'devframe/types'
+import type { ConnectionMeta, DevframeDefinition, DevframeServiceInput, DevframeSseOptions, DevframeStorageScope, DevframeWsOptions, McpRouteOptions } from 'devframe/types'
 import type { Buffer } from 'node:buffer'
 import type { IncomingMessage, Server as NodeHttpServer, ServerResponse } from 'node:http'
 import type { Duplex } from 'node:stream'
@@ -21,6 +21,7 @@ import { resolveClientModuleSpecifier } from '../client-modules'
 import { DEVFRAMES_HUB_BASE, DOCK_RENDERERS_STATE_KEY, normalizeHubBase } from '../constants'
 import { createHubContext } from './context'
 import { diagnostics } from './diagnostics'
+import { prepareDevframe } from './install-devframe'
 
 /** A `devframes` entry with per-mount dock customization. */
 export interface HubDevframeEntry {
@@ -171,6 +172,14 @@ export interface InitHubOptions {
    * (category, icon, a `clientScript` to run in the host page, …).
    */
   devframes?: DevframesInput
+  /**
+   * Host-level wire services to install, on top of whatever the mounted
+   * devframes declare. Constructed (option sets merged) at the pre-setup
+   * barrier, so every devframe's `setup` sees them ready. Reach for this to
+   * configure a shared service centrally — e.g.
+   * `services: [createShikiService({ themes })]`.
+   */
+  services?: DevframeServiceInput[]
   /**
    * Extra RPC declarations registered at context creation, alongside the
    * hub built-ins — forwarded to `createHubContext`'s
@@ -499,9 +508,14 @@ export function initHub(options: InitHubOptions): HubInstance {
       }
 
       const devframes = await resolveDevframesInput(options.devframes ?? [])
-      // Mount each devframe under `/` — its SPA, its meta, and its
-      // auto-registered iframe dock — after guarding the id against the
-      // reserved hub filenames that live directly under the base.
+      // Host-level services declared on `initHub` join the pre-setup
+      // collection alongside every devframe's own declared services.
+      for (const input of options.services ?? [])
+        void ctx.services.install(input)
+      // Pass 1 — mount each devframe under `/` (SPA, meta, iframe
+      // dock) and queue its declared services, guarding the id against the
+      // reserved hub filenames. No setup yet.
+      const setups: (() => Promise)[] = []
       for (const { devframe: def, dock } of devframes) {
         if ((RESERVED_HUB_PATHS as readonly string[]).includes(def.id))
           throw diagnostics.DF8000({ id: def.id })
@@ -511,10 +525,19 @@ export function initHub(options: InitHubOptions): HubInstance {
         if (!/^[\w.-]+$/.test(def.id))
           throw diagnostics.DF8004({ id: def.id })
         const frameBase = withTrailingSlash(joinURL(base, def.id))
-        await ctx.install(def, { base: frameBase, ...(dock ? { dock } : {}) })
+        const run = await prepareDevframe(ctx, def, { base: frameBase, ...(dock ? { dock } : {}) })
+        if (run)
+          setups.push(run)
         frames.push({ id: def.id, base: frameBase, title: def.name })
       }
 
+      // Construct every collected service once, then run the setups — so a
+      // devframe's setup consumes services (its own or another devframe's)
+      // synchronously via `ctx.services.get`.
+      await ctx.services.ready()
+      for (const run of setups)
+        await run()
+
       await options.configure?.(ctx)
 
       // The UI slot publishes its own static config (branding, dock
@@ -523,11 +546,6 @@ export function initHub(options: InitHubOptions): HubInstance {
       // into the connection meta right after this `init` returns.
       await options.ui?.setup?.(ctx)
 
-      // Wire-services barrier: every service declared by an installed
-      // devframe (or installed explicitly during `configure`) is constructed
-      // once here, with its option sets merged across declarers.
-      await ctx.services.ready()
-
       // Publish the renderer manifest — one `ClientScriptEntry` per dock
       // `type`, `importFrom` base-absolute so it resolves to the served module
       // from any page depth. Clients read it from shared state and import a
diff --git a/packages/hub/src/node/install-devframe.ts b/packages/hub/src/node/install-devframe.ts
index a9478c76..df8024ba 100644
--- a/packages/hub/src/node/install-devframe.ts
+++ b/packages/hub/src/node/install-devframe.ts
@@ -48,11 +48,22 @@ function nextAvailableDockId(views: DevframeHubContext['docks']['views'], baseId
  * machinery — e.g. `@vitejs/devtools-kit`'s `createPluginFromDevframe`
  * returns a Vite `Plugin` whose `devtools.setup` ultimately delegates here.
  */
-export async function installDevframe(
+/**
+ * Phase one of an install: run the duplication guard, serve the SPA + meta,
+ * register the iframe dock, and queue the definition's declarative wire
+ * services — everything up to (but not including) `setup(ctx)`. Returns a
+ * deferred setup thunk, or `null` when the devframe was deduplicated.
+ *
+ * The hub's initial batch uses this to collect every devframe's services
+ * across the whole hub, `ready()` them once, and only then run the setups —
+ * so services are ready before any setup, and a plugin can consume a service
+ * another plugin declared regardless of mount order.
+ */
+export async function prepareDevframe(
   ctx: DevframeHubContext,
   d: DevframeDefinition,
   options: InstallDevframeOptions = {},
-): Promise {
+): Promise<(() => Promise) | null> {
   const strategy = d.duplicationStrategy ?? 'warn'
   const isDuplicate = ctx.docks.views.has(d.id)
 
@@ -63,7 +74,7 @@ export async function installDevframe(
       diagnostics.DF8105({ id: d.id, name: d.name })
     // 'warn' and 'silent' both deduplicate: keep the first registration
     // and drop this later one.
-    return
+    return null
   }
 
   // The 'duplicate' strategy lets instances coexist, so the dock id (and,
@@ -110,11 +121,31 @@ export async function installDevframe(
     url: base,
   } as DevframeViewIframe)
 
-  // Queue the definition's declarative wire services ahead of its setup so
-  // their option sets precede setup-time installs in the merge order. The
-  // hub fires the `ctx.services.ready()` barrier once every devframe (and
-  // the host's own configuration) has installed.
+  // Queue the definition's declarative wire services. They're constructed at
+  // the `ctx.services.ready()` barrier the hub fires before running setups.
   for (const input of d.services ?? [])
     void ctx.services.install(input, { resolveFrom: d.importMetaUrl })
-  await d.setup(ctx)
+
+  return () => Promise.resolve(d.setup(ctx))
+}
+
+/**
+ * Install a {@link DevframeDefinition} into a hub in one call — serve its SPA,
+ * register its dock, ready its services, and run `setup(ctx)`. The imperative
+ * counterpart to the hub's declarative `devframes` list (which batches the
+ * phases via {@link prepareDevframe}); use it from `configure(ctx)` or
+ * wherever you hold the context to plug in an extra devframe after startup.
+ */
+export async function installDevframe(
+  ctx: DevframeHubContext,
+  d: DevframeDefinition,
+  options: InstallDevframeOptions = {},
+): Promise {
+  const run = await prepareDevframe(ctx, d, options)
+  if (!run)
+    return
+  // `ready()` is idempotent: after the hub's initial barrier this constructs
+  // the just-queued services immediately, before this devframe's setup.
+  await ctx.services.ready()
+  await run()
 }
diff --git a/plugins/assets/package.json b/plugins/assets/package.json
index 5e241e85..4f2a84cf 100644
--- a/plugins/assets/package.json
+++ b/plugins/assets/package.json
@@ -48,6 +48,7 @@
   },
   "peerDependencies": {
     "@devframes/plugin-assets--assets": "workspace:*",
+    "@devframes/service-shiki": "workspace:*",
     "devframe": "workspace:*",
     "vite": "^7.0.0 || ^8.0.0"
   },
@@ -55,11 +56,15 @@
     "@devframes/plugin-assets--assets": {
       "optional": true
     },
+    "@devframes/service-shiki": {
+      "optional": true
+    },
     "vite": {
       "optional": true
     }
   },
   "dependencies": {
+    "@devframes/service-open": "workspace:*",
     "cac": "catalog:deps",
     "chokidar": "catalog:deps",
     "image-meta": "catalog:deps",
@@ -71,6 +76,7 @@
   "devDependencies": {
     "@antfu/design": "catalog:frontend",
     "@devframes/plugin-assets--assets": "workspace:*",
+    "@devframes/service-shiki": "workspace:*",
     "@devframes/vite": "workspace:*",
     "@iconify-json/ph": "catalog:frontend",
     "@storybook/addon-a11y": "catalog:storybook",
diff --git a/plugins/assets/src/index.ts b/plugins/assets/src/index.ts
index e15bcb55..63032c3d 100644
--- a/plugins/assets/src/index.ts
+++ b/plugins/assets/src/index.ts
@@ -1,4 +1,5 @@
 import type { DevframeDefinition, RemoteAssets } from 'devframe'
+import process from 'node:process'
 import { defineDevframe } from 'devframe'
 import { resolve } from 'pathe'
 import pkg from '../package.json' with { type: 'json' }
@@ -96,6 +97,11 @@ export interface AssetsDevframeOptions {
  */
 export function createAssetsDevframe(options: AssetsDevframeOptions = {}): DevframeDefinition {
   const id = options.id ?? DEFAULT_ID
+  // Resolve the managed dir at factory time (process.cwd() here equals the
+  // adapter's ctx.cwd — same process) so it can be declared as a service-open
+  // allowed root before any setup runs. Only matters when `dir` points
+  // outside the workspace; an in-workspace `public/` is already allowed.
+  const dir = options.dir ? resolve(process.cwd(), options.dir) : resolve(process.cwd(), 'public')
   const distDir = options.distDir ?? remoteAssets
   const write = options.write ?? true
   const serveStatic = options.serveStatic ?? false
@@ -125,9 +131,18 @@ export function createAssetsDevframe(options: AssetsDevframeOptions = {}): Devfr
       },
     },
     dock: { category: '~builtin' },
+    // Both wire services are declared, not imperatively installed: devframe
+    // constructs them (deep-merging options across every declarer) before
+    // setup runs. `service-open` gets the managed dir as an allowed root so
+    // out-of-workspace dirs open; the client hits it directly with the
+    // dev-only absolute `fsPath`. `service-shiki` backs server-highlighted
+    // text previews, with a plain `
` fallback when it isn't advertised.
+    services: [
+      { package: '@devframes/service-open', options: { roots: [dir] } },
+      { package: '@devframes/service-shiki' },
+    ],
     async setup(ctx, info) {
       const readOnlyFlag = info?.flags?.readOnly === true
-      const dir = options.dir ? resolve(ctx.cwd, options.dir) : resolve(ctx.cwd, 'public')
       await setupAssets(ctx, {
         dir,
         write: readOnlyFlag ? false : write,
diff --git a/plugins/assets/src/node/index.ts b/plugins/assets/src/node/index.ts
index f14f9838..cf1c3416 100644
--- a/plugins/assets/src/node/index.ts
+++ b/plugins/assets/src/node/index.ts
@@ -2,7 +2,7 @@ import type { DevframeNodeContext } from 'devframe'
 import { existsSync } from 'node:fs'
 import fsp from 'node:fs/promises'
 import { UPLOAD_CHANNEL } from '../rpc/functions/upload'
-import { alwaysFunctions, readFunctions, writeFunctions } from '../rpc/index'
+import { readFunctions, writeFunctions } from '../rpc/index'
 import { configureAssets } from './context'
 import { watchAssetsDir } from './watcher'
 
@@ -64,8 +64,6 @@ export async function setupAssets(ctx: DevframeNodeContext, options: SetupAssets
 
   for (const fn of readFunctions)
     ctx.rpc.register(fn)
-  for (const fn of alwaysFunctions)
-    ctx.rpc.register(fn)
   if (options.write) {
     for (const fn of writeFunctions)
       ctx.rpc.register(fn)
diff --git a/plugins/assets/src/node/scanner.ts b/plugins/assets/src/node/scanner.ts
index e50b5821..01b21237 100644
--- a/plugins/assets/src/node/scanner.ts
+++ b/plugins/assets/src/node/scanner.ts
@@ -31,8 +31,12 @@ function toPublicPath(baseURL: string, posixPath: string): string {
   return joinURL(baseURL, encoded)
 }
 
-/** Builds an {@link AssetInfo} from an already-resolved `fs.Stats`. */
-export function statToAssetInfo(dir: string, baseURL: string, relPath: string, stat: Stats): AssetInfo {
+/**
+ * Builds an {@link AssetInfo} from an already-resolved `fs.Stats`. Pass
+ * `includeFsPath` (dev mode only) to attach the absolute `fsPath` the client
+ * hands to the open wire service.
+ */
+export function statToAssetInfo(dir: string, baseURL: string, relPath: string, stat: Stats, includeFsPath = false): AssetInfo {
   const posixPath = relPath.replace(/\\/g, '/')
   return {
     path: posixPath,
@@ -40,17 +44,18 @@ export function statToAssetInfo(dir: string, baseURL: string, relPath: string, s
     publicPath: toPublicPath(baseURL, posixPath),
     size: stat.size,
     mtime: stat.mtimeMs,
+    ...(includeFsPath ? { fsPath: join(dir, relPath) } : {}),
   }
 }
 
 /** Recursively lists every file under `dir`, sorted alphabetically by path. */
-export async function scanAssets(dir: string, baseURL: string): Promise {
+export async function scanAssets(dir: string, baseURL: string, includeFsPath = false): Promise {
   const files = await glob(['**/*'], { cwd: dir, onlyFiles: true, dot: false })
 
   const infos = await Promise.all(files.map(async (relPath): Promise => {
     try {
       const stat = await fsp.lstat(join(dir, relPath))
-      return statToAssetInfo(dir, baseURL, relPath, stat)
+      return statToAssetInfo(dir, baseURL, relPath, stat, includeFsPath)
     }
     catch {
       // Removed between the glob scan and the stat call — drop it silently,
diff --git a/plugins/assets/src/rpc/functions/list.ts b/plugins/assets/src/rpc/functions/list.ts
index 5c94a880..f7cceb39 100644
--- a/plugins/assets/src/rpc/functions/list.ts
+++ b/plugins/assets/src/rpc/functions/list.ts
@@ -13,6 +13,7 @@ export const assetInfoSchema = s.object({
   publicPath: s.string(),
   size: s.number(),
   mtime: s.number(),
+  fsPath: s.optional(s.string()),
 })
 
 export const list = defineAssetsRpc({
@@ -34,7 +35,8 @@ export const list = defineAssetsRpc({
       // The RPC runtime awaits handlers before validating `returns`; its
       // public setup type currently models schema-backed returns as
       // synchronous.
-      handler: (async (): Promise => scanAssets(assets.dir, assets.baseURL)) as any,
+      // `fsPath` is dev-only — never baked into a static build's dump.
+      handler: (async (): Promise => scanAssets(assets.dir, assets.baseURL, ctx.mode === 'dev')) as any,
     }
   },
 })
diff --git a/plugins/assets/src/rpc/functions/open-in-editor.ts b/plugins/assets/src/rpc/functions/open-in-editor.ts
deleted file mode 100644
index 313c125a..00000000
--- a/plugins/assets/src/rpc/functions/open-in-editor.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { DevframeNodeContext } from 'devframe'
-import { createDefineWrapperWithContext } from 'devframe/rpc'
-import { launchEditor } from 'devframe/utils/launch-editor'
-import { s } from 'devframe/utils/simple-schema'
-import { getAssetsContext } from '../../node/context'
-
-const defineAssetsRpc = createDefineWrapperWithContext()
-
-/**
- * Reuses devframe's `launchEditor` utility (the same one backing the core
- * `devframe:open-in-editor` recipe) but resolves the path against the
- * managed directory first, so the client only ever sends a root-relative
- * path — never the server's absolute filesystem layout.
- */
-export const openInEditor = defineAssetsRpc({
-  name: 'devframes:plugin:assets:open-in-editor',
-  type: 'action',
-  jsonSerializable: true,
-  args: [s.string()],
-  returns: s.void(),
-  agent: {
-    title: 'Open an asset in the editor',
-    description: 'Open an asset in the user\'s configured editor.',
-    safety: 'action',
-    tags: ['assets'],
-  },
-  setup: (ctx) => {
-    const assets = getAssetsContext(ctx)
-    return {
-      // See `list.ts` for why the async handler is cast.
-      handler: (async (path: string): Promise => {
-        launchEditor(assets.resolvePath(path))
-      }) as any,
-    }
-  },
-})
diff --git a/plugins/assets/src/rpc/functions/rename.ts b/plugins/assets/src/rpc/functions/rename.ts
index 2857c11e..8abe2b25 100644
--- a/plugins/assets/src/rpc/functions/rename.ts
+++ b/plugins/assets/src/rpc/functions/rename.ts
@@ -52,7 +52,7 @@ export const rename = defineAssetsRpc({
 
         if (from === to) {
           const stat = await fsp.lstat(from)
-          return statToAssetInfo(assets.dir, assets.baseURL, path, stat)
+          return statToAssetInfo(assets.dir, assets.baseURL, path, stat, true)
         }
 
         const targetExists = await fsp.access(to).then(() => true).catch(() => false)
@@ -70,7 +70,7 @@ export const rename = defineAssetsRpc({
         }
 
         const stat = await fsp.lstat(to)
-        return statToAssetInfo(assets.dir, assets.baseURL, nextRelPath, stat)
+        return statToAssetInfo(assets.dir, assets.baseURL, nextRelPath, stat, true)
       }) as any,
     }
   },
diff --git a/plugins/assets/src/rpc/functions/reveal-in-folder.ts b/plugins/assets/src/rpc/functions/reveal-in-folder.ts
deleted file mode 100644
index 05902c45..00000000
--- a/plugins/assets/src/rpc/functions/reveal-in-folder.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import type { DevframeNodeContext } from 'devframe'
-import { createDefineWrapperWithContext } from 'devframe/rpc'
-import { open } from 'devframe/utils/open'
-import { s } from 'devframe/utils/simple-schema'
-import { dirname } from 'pathe'
-import { getAssetsContext } from '../../node/context'
-
-const defineAssetsRpc = createDefineWrapperWithContext()
-
-/**
- * Reuses devframe's `open` utility (the same one backing the core
- * `devframe:open-in-finder` recipe), opening the asset's containing folder
- * so it's revealed in the OS file manager rather than launched with its
- * default app (which `download` already covers).
- */
-export const revealInFolder = defineAssetsRpc({
-  name: 'devframes:plugin:assets:reveal-in-folder',
-  type: 'action',
-  jsonSerializable: true,
-  args: [s.string()],
-  returns: s.void(),
-  agent: {
-    title: 'Reveal an asset in the file manager',
-    description: 'Open the OS file manager at the asset\'s containing folder.',
-    safety: 'action',
-    tags: ['assets'],
-  },
-  setup: (ctx) => {
-    const assets = getAssetsContext(ctx)
-    return {
-      // See `list.ts` for why the async handler is cast.
-      handler: (async (path: string): Promise => {
-        await open(dirname(assets.resolvePath(path)))
-      }) as any,
-    }
-  },
-})
diff --git a/plugins/assets/src/rpc/index.ts b/plugins/assets/src/rpc/index.ts
index 2caeecac..8b6e98e3 100644
--- a/plugins/assets/src/rpc/index.ts
+++ b/plugins/assets/src/rpc/index.ts
@@ -3,26 +3,18 @@ import { capabilities } from './functions/capabilities'
 import { deleteAssets } from './functions/delete'
 import { list } from './functions/list'
 import { mkdir } from './functions/mkdir'
-import { openInEditor } from './functions/open-in-editor'
 import { readImageMeta } from './functions/read-image-meta'
 import { readText } from './functions/read-text'
 import { rename } from './functions/rename'
-import { revealInFolder } from './functions/reveal-in-folder'
 import { upload } from './functions/upload'
 
 /** Read-only RPC — always registered. */
 export const readFunctions = [list, readImageMeta, readText, capabilities] as const
 
-/**
- * Informational actions — launch external apps, never touch the managed
- * directory's contents. Always registered regardless of `write`.
- */
-export const alwaysFunctions = [openInEditor, revealInFolder] as const
-
 /** Mutating RPC — registered only when write actions are enabled. */
 export const writeFunctions = [upload, rename, deleteAssets, mkdir] as const
 
-export const serverFunctions = [...readFunctions, ...alwaysFunctions, ...writeFunctions] as const
+export const serverFunctions = [...readFunctions, ...writeFunctions] as const
 
 declare module 'devframe' {
   interface DevframeRpcServerFunctions extends RpcDefinitionsToFunctions {}
@@ -33,10 +25,8 @@ export { capabilities } from './functions/capabilities'
 export { deleteAssets } from './functions/delete'
 export { assetInfoSchema, list } from './functions/list'
 export { mkdir } from './functions/mkdir'
-export { openInEditor } from './functions/open-in-editor'
 export { readImageMeta } from './functions/read-image-meta'
 export { readText } from './functions/read-text'
 export type { RenameArgs } from './functions/rename'
 export { rename } from './functions/rename'
-export { revealInFolder } from './functions/reveal-in-folder'
 export { upload, UPLOAD_CHANNEL } from './functions/upload'
diff --git a/plugins/assets/src/spa/app/components/AssetDetails.vue b/plugins/assets/src/spa/app/components/AssetDetails.vue
index 37036930..61a1af3c 100644
--- a/plugins/assets/src/spa/app/components/AssetDetails.vue
+++ b/plugins/assets/src/spa/app/components/AssetDetails.vue
@@ -1,4 +1,7 @@
 
 
diff --git a/plugins/messages/src/index.ts b/plugins/messages/src/index.ts
index d6e81494..3bac4f2b 100644
--- a/plugins/messages/src/index.ts
+++ b/plugins/messages/src/index.ts
@@ -71,6 +71,9 @@ export function createMessagesDevframe(options: MessagesDevframeOptions = {}): D
     dock: {
       category: '~builtin',
     },
+    // Backs the detail panel's "open file" affordance; the panel hides it
+    // when the service isn't advertised.
+    services: [{ package: '@devframes/service-open' }],
     setup(ctx) {
       setupMessages(ctx)
     },
diff --git a/plugins/messages/src/node/index.ts b/plugins/messages/src/node/index.ts
index def331be..60104df2 100644
--- a/plugins/messages/src/node/index.ts
+++ b/plugins/messages/src/node/index.ts
@@ -1,5 +1,4 @@
 import type { DevframeNodeContext } from 'devframe'
-import { commonRpcFunctions } from 'devframe/recipes/common-rpc-functions'
 import { PLUGIN_ID } from '../constants'
 import { diagnostics } from '../diagnostics'
 import { getMessagesHost } from '../rpc/functions/_define'
@@ -18,18 +17,12 @@ export function setupMessages(ctx: DevframeNodeContext): void {
   if (!getMessagesHost(ctx))
     diagnostics.DP_MESSAGES_0001({ id: PLUGIN_ID })
 
+  // The detail panel's "open file" affordance calls the
+  // `@devframes/service-open` wire service (declared in the definition's
+  // `services`) directly from the client — the service resolves the
+  // workspace-relative file position itself, so the plugin needs no bridge.
   for (const fn of serverFunctions)
     ctx.rpc.register(fn)
-
-  // The detail panel's "open file" affordance uses devframe's prebuilt
-  // `devframe:open-in-editor` recipe. Another tool on the same connection
-  // may have registered the helpers already — skip those. The recipes are
-  // context-free (`SetupContext = undefined`, plain handlers); widening to
-  // the node collector's context is safe.
-  for (const fn of commonRpcFunctions) {
-    if (!ctx.rpc.definitions.has(fn.name))
-      ctx.rpc.register(fn as unknown as Parameters[0])
-  }
 }
 
 export { serverFunctions }
diff --git a/plugins/messages/test/_utils.ts b/plugins/messages/test/_utils.ts
index c0253d4d..2eab6fb5 100644
--- a/plugins/messages/test/_utils.ts
+++ b/plugins/messages/test/_utils.ts
@@ -82,6 +82,11 @@ async function boot(options: BootOptions): Promise {
   const ctx = options.hub
     ? await createHubContext({ cwd: process.cwd(), mode: 'dev', host: h3Host })
     : await createHostContext({ cwd: process.cwd(), mode: 'dev', host: h3Host })
+  // Mirror the adapters: queue the definition's declared wire services,
+  // ready them, THEN run setup (services ready before setup).
+  for (const input of messagesDevframe.services ?? [])
+    void ctx.services.install(input, { resolveFrom: messagesDevframe.packageName })
+  await ctx.services.ready()
   await messagesDevframe.setup(ctx)
 
   const metaPath = `${basePath}${DEVFRAME_CONNECTION_META_FILENAME}`
diff --git a/plugins/messages/test/dev-server.test.ts b/plugins/messages/test/dev-server.test.ts
index c7fc7e43..58d968e8 100644
--- a/plugins/messages/test/dev-server.test.ts
+++ b/plugins/messages/test/dev-server.test.ts
@@ -43,14 +43,18 @@ describe('messages dev-server (hub context)', () => {
     expect(meta.websocket).toBe(server.port)
   })
 
-  it('registers the open-in-editor recipe alongside the feed RPCs', () => {
+  it('installs the open service (called directly by the client) alongside the feed RPCs', () => {
     const names = Array.from(server.ctx.rpc.definitions.keys())
     expect(names).toContain('devframes:plugin:messages:list')
     expect(names).toContain('devframes:plugin:messages:add')
     expect(names).toContain('devframes:plugin:messages:update')
     expect(names).toContain('devframes:plugin:messages:remove')
     expect(names).toContain('devframes:plugin:messages:clear')
-    expect(names).toContain('devframe:open-in-editor')
+    // The detail panel calls the declared `@devframes/service-open` wire
+    // service directly; the plugin registers no open-file bridge of its own.
+    expect(names).not.toContain('devframes:plugin:messages:open-file')
+    expect(names).toContain('devframes:service:open:open-in-editor')
+    expect(server.ctx.services.has('@devframes/service-open')).toBe(true)
   })
 
   it('lists server-side entries and delta-syncs from a cursor', async () => {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8464b99a..d4507183 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -115,6 +115,9 @@ catalogs:
     perfect-debounce:
       specifier: ^2.1.0
       version: 2.1.0
+    shiki:
+      specifier: ^4.4.3
+      version: 4.4.3
     structured-clone-es:
       specifier: ^2.0.1
       version: 2.0.1
@@ -1568,6 +1571,9 @@ importers:
 
   plugins/assets:
     dependencies:
+      '@devframes/service-open':
+        specifier: workspace:*
+        version: link:../../services/open
       cac:
         specifier: catalog:deps
         version: 7.0.0
@@ -1596,6 +1602,9 @@ importers:
       '@devframes/plugin-assets--assets':
         specifier: workspace:*
         version: link:assets-pkg
+      '@devframes/service-shiki':
+        specifier: workspace:*
+        version: link:../../services/shiki
       '@devframes/vite':
         specifier: workspace:*
         version: link:../../packages/vite
@@ -1811,7 +1820,7 @@ importers:
         version: 1.2.17
       '@pierre/diffs':
         specifier: catalog:frontend
-        version: 1.2.12(@shikijs/themes@4.3.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
+        version: 1.2.12(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
       '@radix-ui/react-scroll-area':
         specifier: catalog:frontend
         version: 1.2.18(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -1955,6 +1964,9 @@ importers:
 
   plugins/messages:
     dependencies:
+      '@devframes/service-open':
+        specifier: workspace:*
+        version: link:../../services/open
       cac:
         specifier: catalog:deps
         version: 7.0.0
@@ -2166,6 +2178,44 @@ importers:
 
   plugins/terminals/assets-pkg: {}
 
+  services/open:
+    dependencies:
+      pathe:
+        specifier: catalog:deps
+        version: 2.0.3
+    devDependencies:
+      '@types/node':
+        specifier: catalog:types
+        version: 26.2.0
+      devframe:
+        specifier: workspace:*
+        version: link:../../packages/devframe
+      tsdown:
+        specifier: catalog:build
+        version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(oxc-resolver@11.24.2)(tsx@4.23.12)(typescript@6.0.3)
+      vitest:
+        specifier: catalog:testing
+        version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0))
+
+  services/shiki:
+    dependencies:
+      shiki:
+        specifier: catalog:deps
+        version: 4.4.3
+    devDependencies:
+      '@types/node':
+        specifier: catalog:types
+        version: 26.2.0
+      devframe:
+        specifier: workspace:*
+        version: link:../../packages/devframe
+      tsdown:
+        specifier: catalog:build
+        version: 0.22.14(@volar/typescript@2.4.28(typescript@6.0.3))(oxc-resolver@11.24.2)(tsx@4.23.12)(typescript@6.0.3)
+      vitest:
+        specifier: catalog:testing
+        version: 4.1.10(@types/node@26.2.0)(vite@8.2.1(@types/node@26.2.0)(esbuild@0.28.0)(jiti@2.7.0)(terser@5.47.1)(tsx@4.23.12)(yaml@2.9.0))
+
   storybook:
     dependencies:
       '@antfu/design':
@@ -4950,6 +5000,10 @@ packages:
     resolution: {integrity: sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA==}
     engines: {node: '>=20'}
 
+  '@shikijs/core@4.4.3':
+    resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==}
+    engines: {node: '>=20'}
+
   '@shikijs/engine-javascript@4.3.1':
     resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==}
     engines: {node: '>=20'}
@@ -4958,6 +5012,10 @@ packages:
     resolution: {integrity: sha512-MnIkeqWdVPUWsxlx8gKLVCJFTsqrQJgpTPBPpQwaFeJ56lOnJxj5aN2LUFnfxUEcvOQuNocmbaMnVrCEln6rkw==}
     engines: {node: '>=20'}
 
+  '@shikijs/engine-javascript@4.4.3':
+    resolution: {integrity: sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==}
+    engines: {node: '>=20'}
+
   '@shikijs/engine-oniguruma@4.3.1':
     resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==}
     engines: {node: '>=20'}
@@ -4966,6 +5024,10 @@ packages:
     resolution: {integrity: sha512-GLhowz1+jixjz+wiZ3wMnOn1jTxiFCGl2PkXufivbnwPHKuyw1AYqu5/hbWhZZ2oAb0NP05WUJhYeigY14drnw==}
     engines: {node: '>=20'}
 
+  '@shikijs/engine-oniguruma@4.4.3':
+    resolution: {integrity: sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==}
+    engines: {node: '>=20'}
+
   '@shikijs/langs@4.3.1':
     resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==}
     engines: {node: '>=20'}
@@ -4974,6 +5036,10 @@ packages:
     resolution: {integrity: sha512-8DfeusD+Zdv/eYIDdXyJTUnSMHt+aAWjAOCXV20HNGAHRlInXpG8wh421v6B91WOm9TFwRLN+b/LG5F2NAIojg==}
     engines: {node: '>=20'}
 
+  '@shikijs/langs@4.4.3':
+    resolution: {integrity: sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==}
+    engines: {node: '>=20'}
+
   '@shikijs/primitive@4.3.1':
     resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==}
     engines: {node: '>=20'}
@@ -4982,6 +5048,10 @@ packages:
     resolution: {integrity: sha512-l6fQQKsOMlz72n38fztmSgZ76MO6KSWuw8o+GJ+FhmqrpC9pIOJNQNXGgbb5yX2AwpzlEHwsaLPnk/8o4Fm+rA==}
     engines: {node: '>=20'}
 
+  '@shikijs/primitive@4.4.3':
+    resolution: {integrity: sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==}
+    engines: {node: '>=20'}
+
   '@shikijs/themes@4.3.1':
     resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==}
     engines: {node: '>=20'}
@@ -4990,6 +5060,10 @@ packages:
     resolution: {integrity: sha512-H0CFoL07ddDC2Dd6EdrPYNkRhUR6YCkJlnuYFceYYUJJA5TIm2b5B33qqiDYryBExgbKMndFJPb2u1gTuqO37g==}
     engines: {node: '>=20'}
 
+  '@shikijs/themes@4.4.3':
+    resolution: {integrity: sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==}
+    engines: {node: '>=20'}
+
   '@shikijs/transformers@4.3.1':
     resolution: {integrity: sha512-z6ir0bGDgWcF2FduktEfPgIsdOtIlDiLAjFBgBzE42Q9xHbkkIXZtORHzlLVB71iZP9elEcqKg6keajvOUwE2A==}
     engines: {node: '>=20'}
@@ -5006,6 +5080,10 @@ packages:
     resolution: {integrity: sha512-PFYitV4vpDr/iPCIhnHp+Q4ftic5N5VeNJ3KQ1O8gn3h2ar8qgwMAXF7tq4m1CWaMS60fV4VqF6vfnWH4F7vqQ==}
     engines: {node: '>=20'}
 
+  '@shikijs/types@4.4.3':
+    resolution: {integrity: sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==}
+    engines: {node: '>=20'}
+
   '@shikijs/vscode-textmate@10.0.2':
     resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
 
@@ -5387,9 +5465,6 @@ packages:
   '@types/geojson@7946.0.16':
     resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
 
-  '@types/hast@3.0.4':
-    resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
-
   '@types/hast@3.0.5':
     resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
 
@@ -9338,6 +9413,10 @@ packages:
     resolution: {integrity: sha512-P8F/dFhRevaw2uSdeIYlq/5SXZNY85DPtmXQ947gD1Zj2JqO5AkNvVVBar0Me9JkFx3uzVud/qOtP5ek9NEQGA==}
     engines: {node: '>=20'}
 
+  shiki@4.4.3:
+    resolution: {integrity: sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==}
+    engines: {node: '>=20'}
+
   siginfo@2.0.0:
     resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
 
@@ -12641,10 +12720,10 @@ snapshots:
       '@parcel/watcher-win32-ia32': 2.5.6
       '@parcel/watcher-win32-x64': 2.5.6
 
-  '@pierre/diffs@1.2.12(@shikijs/themes@4.3.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
+  '@pierre/diffs@1.2.12(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
     dependencies:
       '@pierre/theme': 1.1.0
-      '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.3.1)
+      '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.3.1)
       '@shikijs/transformers': 4.3.1
       diff: 9.0.0
       hast-util-to-html: 9.0.5
@@ -12657,10 +12736,10 @@ snapshots:
 
   '@pierre/theme@1.1.0': {}
 
-  '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.3.1)':
+  '@pierre/theming@0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.4.3)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(shiki@4.3.1)':
     optionalDependencies:
       '@pierre/theme': 1.1.0
-      '@shikijs/themes': 4.3.1
+      '@shikijs/themes': 4.4.3
       react: 19.2.8
       react-dom: 19.2.8(react@19.2.8)
       shiki: 4.3.1
@@ -13126,7 +13205,7 @@ snapshots:
       '@shikijs/primitive': 4.3.1
       '@shikijs/types': 4.3.1
       '@shikijs/vscode-textmate': 10.0.2
-      '@types/hast': 3.0.4
+      '@types/hast': 3.0.5
       hast-util-to-html: 9.0.5
 
   '@shikijs/core@4.4.2':
@@ -13137,6 +13216,14 @@ snapshots:
       '@types/hast': 3.0.5
       hast-util-to-html: 9.0.5
 
+  '@shikijs/core@4.4.3':
+    dependencies:
+      '@shikijs/primitive': 4.4.3
+      '@shikijs/types': 4.4.3
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.5
+      hast-util-to-html: 9.0.5
+
   '@shikijs/engine-javascript@4.3.1':
     dependencies:
       '@shikijs/types': 4.3.1
@@ -13149,6 +13236,12 @@ snapshots:
       '@shikijs/vscode-textmate': 10.0.2
       oniguruma-to-es: 4.3.6
 
+  '@shikijs/engine-javascript@4.4.3':
+    dependencies:
+      '@shikijs/types': 4.4.3
+      '@shikijs/vscode-textmate': 10.0.2
+      oniguruma-to-es: 4.3.6
+
   '@shikijs/engine-oniguruma@4.3.1':
     dependencies:
       '@shikijs/types': 4.3.1
@@ -13159,6 +13252,11 @@ snapshots:
       '@shikijs/types': 4.4.2
       '@shikijs/vscode-textmate': 10.0.2
 
+  '@shikijs/engine-oniguruma@4.4.3':
+    dependencies:
+      '@shikijs/types': 4.4.3
+      '@shikijs/vscode-textmate': 10.0.2
+
   '@shikijs/langs@4.3.1':
     dependencies:
       '@shikijs/types': 4.3.1
@@ -13167,6 +13265,10 @@ snapshots:
     dependencies:
       '@shikijs/types': 4.4.2
 
+  '@shikijs/langs@4.4.3':
+    dependencies:
+      '@shikijs/types': 4.4.3
+
   '@shikijs/primitive@4.3.1':
     dependencies:
       '@shikijs/types': 4.3.1
@@ -13179,6 +13281,12 @@ snapshots:
       '@shikijs/vscode-textmate': 10.0.2
       '@types/hast': 3.0.5
 
+  '@shikijs/primitive@4.4.3':
+    dependencies:
+      '@shikijs/types': 4.4.3
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.5
+
   '@shikijs/themes@4.3.1':
     dependencies:
       '@shikijs/types': 4.3.1
@@ -13187,6 +13295,10 @@ snapshots:
     dependencies:
       '@shikijs/types': 4.4.2
 
+  '@shikijs/themes@4.4.3':
+    dependencies:
+      '@shikijs/types': 4.4.3
+
   '@shikijs/transformers@4.3.1':
     dependencies:
       '@shikijs/core': 4.3.1
@@ -13200,13 +13312,18 @@ snapshots:
   '@shikijs/types@4.3.1':
     dependencies:
       '@shikijs/vscode-textmate': 10.0.2
-      '@types/hast': 3.0.4
+      '@types/hast': 3.0.5
 
   '@shikijs/types@4.4.2':
     dependencies:
       '@shikijs/vscode-textmate': 10.0.2
       '@types/hast': 3.0.5
 
+  '@shikijs/types@4.4.3':
+    dependencies:
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.5
+
   '@shikijs/vscode-textmate@10.0.2': {}
 
   '@simple-git/args-pathspec@1.0.3': {}
@@ -13674,10 +13791,6 @@ snapshots:
 
   '@types/geojson@7946.0.16': {}
 
-  '@types/hast@3.0.4':
-    dependencies:
-      '@types/unist': 3.0.3
-
   '@types/hast@3.0.5':
     dependencies:
       '@types/unist': 3.0.3
@@ -16080,7 +16193,7 @@ snapshots:
 
   hast-util-to-html@9.0.5:
     dependencies:
-      '@types/hast': 3.0.4
+      '@types/hast': 3.0.5
       '@types/unist': 3.0.3
       ccount: 2.0.1
       comma-separated-tokens: 2.0.3
@@ -16094,7 +16207,7 @@ snapshots:
 
   hast-util-whitespace@3.0.0:
     dependencies:
-      '@types/hast': 3.0.4
+      '@types/hast': 3.0.5
 
   he@1.2.0: {}
 
@@ -16680,7 +16793,7 @@ snapshots:
 
   mdast-util-to-hast@13.2.1:
     dependencies:
-      '@types/hast': 3.0.4
+      '@types/hast': 3.0.5
       '@types/mdast': 4.0.4
       '@ungap/structured-clone': 1.3.1
       devlop: 1.1.0
@@ -18477,7 +18590,7 @@ snapshots:
       '@shikijs/themes': 4.3.1
       '@shikijs/types': 4.3.1
       '@shikijs/vscode-textmate': 10.0.2
-      '@types/hast': 3.0.4
+      '@types/hast': 3.0.5
 
   shiki@4.4.2:
     dependencies:
@@ -18490,6 +18603,17 @@ snapshots:
       '@shikijs/vscode-textmate': 10.0.2
       '@types/hast': 3.0.5
 
+  shiki@4.4.3:
+    dependencies:
+      '@shikijs/core': 4.4.3
+      '@shikijs/engine-javascript': 4.4.3
+      '@shikijs/engine-oniguruma': 4.4.3
+      '@shikijs/langs': 4.4.3
+      '@shikijs/themes': 4.4.3
+      '@shikijs/types': 4.4.3
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.5
+
   siginfo@2.0.0: {}
 
   signal-exit@3.0.7: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index c928edd5..ca6da25a 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -27,6 +27,7 @@ packages:
   - packages/*
   - plugins/*
   - plugins/*/assets-pkg
+  - services/*
   - examples/*
   - storybook
   - docs
@@ -99,6 +100,7 @@ catalogs:
     parse5: ^8.0.1
     pathe: ^2.0.3
     perfect-debounce: ^2.1.0
+    shiki: ^4.4.3
     structured-clone-es: ^2.0.1
     tinyexec: ^1.3.0
     tinyglobby: ^0.2.17
diff --git a/services/open/package.json b/services/open/package.json
new file mode 100644
index 00000000..f818b752
--- /dev/null
+++ b/services/open/package.json
@@ -0,0 +1,49 @@
+{
+  "name": "@devframes/service-open",
+  "type": "module",
+  "version": "0.9.0",
+  "description": "Devframe wire service that opens files in the user's editor or reveals them in the OS file explorer.",
+  "author": "Anthony Fu ",
+  "license": "MIT",
+  "homepage": "https://github.com/devframes/devframe#readme",
+  "repository": {
+    "directory": "services/open",
+    "type": "git",
+    "url": "git+https://github.com/devframes/devframe.git"
+  },
+  "bugs": "https://github.com/devframes/devframe/issues",
+  "keywords": [
+    "devframe",
+    "devframe-service",
+    "devtools",
+    "open-in-editor"
+  ],
+  "sideEffects": false,
+  "exports": {
+    ".": "./dist/index.mjs",
+    "./package.json": "./package.json"
+  },
+  "types": "./dist/index.d.mts",
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsdown",
+    "watch": "tsdown --watch",
+    "prepack": "turbo run build --filter=@devframes/service-open",
+    "test": "vitest run",
+    "typecheck": "tsc --noEmit"
+  },
+  "peerDependencies": {
+    "devframe": "workspace:*"
+  },
+  "dependencies": {
+    "pathe": "catalog:deps"
+  },
+  "devDependencies": {
+    "@types/node": "catalog:types",
+    "devframe": "workspace:*",
+    "tsdown": "catalog:build",
+    "vitest": "catalog:testing"
+  }
+}
diff --git a/services/open/src/diagnostics.ts b/services/open/src/diagnostics.ts
new file mode 100644
index 00000000..447352d2
--- /dev/null
+++ b/services/open/src/diagnostics.ts
@@ -0,0 +1,14 @@
+import { defineDiagnostics } from 'devframe/utils/nostics'
+
+// Uses the service's own `DS_OPEN_` prefix per the built-in convention,
+// keeping it collision-free with devframe core (`DF00xx`), the hub
+// (`DF8xxx`), and the plugins (`DP__`).
+export const diagnostics = defineDiagnostics({
+  docsBase: 'https://devfra.me/errors',
+  codes: {
+    DS_OPEN_0002: {
+      why: (p: { path: string }) => `Refusing to open "${p.path}": the path is outside the workspace root and every configured extra root.`,
+      fix: 'The open service only touches files under the workspace root by default. Pass additional allowed directories via the service\'s `roots` option when your tool manages files elsewhere (e.g. a global storage dir).',
+    },
+  },
+})
diff --git a/services/open/src/index.ts b/services/open/src/index.ts
new file mode 100644
index 00000000..b4017f2e
--- /dev/null
+++ b/services/open/src/index.ts
@@ -0,0 +1,140 @@
+import type { KnownEditor } from 'devframe/recipes/common-rpc-functions'
+import type { DevframeServiceDefinition } from 'devframe/types'
+import { defineRpcFunction } from 'devframe'
+import { KNOWN_EDITORS } from 'devframe/recipes/common-rpc-functions'
+import { s } from 'devframe/utils/simple-schema'
+import { isAbsolute, relative, resolve } from 'pathe'
+import pkg from '../package.json' with { type: 'json' }
+import { diagnostics } from './diagnostics'
+
+export const OPEN_SERVICE_PACKAGE = '@devframes/service-open'
+export const OPEN_SERVICE_SCOPE = 'devframes:service:open'
+
+export interface OpenServiceOptions {
+  /**
+   * Preferred editor command — one of the `KNOWN_EDITORS` `launch-editor`
+   * recognizes. Auto-detected (via `LAUNCH_EDITOR` and common defaults)
+   * when omitted. On merge, the later installer's choice wins.
+   */
+  editor?: KnownEditor
+  /**
+   * Additional directories files may be opened from, on top of the
+   * context's `workspaceRoot` — e.g. a plugin's managed storage dir that
+   * lives outside the workspace. Merged as a union across installers.
+   */
+  roots?: string[]
+}
+
+export interface OpenInEditorInput {
+  /**
+   * File to open — absolute, or relative to the service's `workspaceRoot`
+   * (so a client with only a workspace-relative path, e.g. a message's file
+   * position, can call this directly without a server-side bridge).
+   */
+  path: string
+  line?: number
+  column?: number
+  /** Per-call editor override (one of `KNOWN_EDITORS`). */
+  editor?: KnownEditor
+}
+
+export interface OpenServiceApi {
+  /** Open a file (optionally at a line/column) in the user's editor. */
+  openInEditor: (input: OpenInEditorInput) => Promise
+  /** Reveal a path in the OS file explorer. */
+  openInFinder: (input: { path: string }) => Promise
+}
+
+declare module 'devframe' {
+  interface DevframeRpcServerFunctions {
+    'devframes:service:open:open-in-editor': (input: OpenInEditorInput) => Promise
+    'devframes:service:open:open-in-finder': (input: { path: string }) => Promise
+  }
+  interface DevframeServicesRegistry {
+    '@devframes/service-open': OpenServiceApi
+  }
+  interface DevframeServicesScopeRegistry {
+    '@devframes/service-open': 'devframes:service:open'
+  }
+}
+
+/**
+ * The open wire service — `open-in-editor` / `open-in-finder` RPC shared by
+ * every plugin on the host, replacing per-plugin registrations of the
+ * (deprecated) `devframe/recipes/common-rpc-functions` recipes. Paths may be
+ * absolute or relative to the `workspaceRoot`; the service refuses paths
+ * outside the workspace root and the configured extra
+ * {@link OpenServiceOptions.roots} (`DS_OPEN_0002`), and gates editor
+ * commands to the `KNOWN_EDITORS` picklist so the RPC surface can't spawn an
+ * arbitrary command.
+ */
+export function createOpenService(options?: OpenServiceOptions): DevframeServiceDefinition {
+  return {
+    package: OPEN_SERVICE_PACKAGE,
+    version: pkg.version,
+    scope: OPEN_SERVICE_SCOPE,
+    options,
+    // Option sets from multiple installers merge via devframe's default
+    // deep-merge: `roots` union, `editor` last-wins.
+    setup(ctx, { options }) {
+      const allowedRoots = [ctx.workspaceRoot, ...(options?.roots ?? [])].map(root => resolve(root))
+
+      /**
+       * Resolve `path` (relative paths against `workspaceRoot`) and assert it
+       * lands inside one of the allowed roots, or throw.
+       */
+      function assertAllowedPath(path: string): string {
+        const resolved = isAbsolute(path) ? resolve(path) : resolve(ctx.workspaceRoot, path)
+        const contained = allowedRoots.some((root) => {
+          const rel = relative(root, resolved)
+          return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
+        })
+        if (!contained)
+          throw diagnostics.DS_OPEN_0002({ path })
+        return resolved
+      }
+
+      const api: OpenServiceApi = {
+        async openInEditor(input) {
+          const path = assertAllowedPath(input.path)
+          const target = input.line != null
+            ? `${path}:${input.line}${input.column != null ? `:${input.column}` : ''}`
+            : path
+          const { launchEditor } = await import('devframe/utils/launch-editor')
+          launchEditor(target, input.editor ?? options?.editor)
+        },
+        async openInFinder(input) {
+          const path = assertAllowedPath(input.path)
+          const { open } = await import('devframe/utils/open')
+          await open(path)
+        },
+      }
+
+      ctx.rpc.register(defineRpcFunction({
+        name: 'open-in-editor',
+        type: 'action',
+        jsonSerializable: true,
+        args: [s.object({
+          path: s.string(),
+          line: s.optional(s.number()),
+          column: s.optional(s.number()),
+          editor: s.optional(s.picklist(KNOWN_EDITORS)),
+        })],
+        returns: s.void(),
+        handler: input => api.openInEditor(input),
+      }))
+      ctx.rpc.register(defineRpcFunction({
+        name: 'open-in-finder',
+        type: 'action',
+        jsonSerializable: true,
+        args: [s.object({ path: s.string() })],
+        returns: s.void(),
+        handler: input => api.openInFinder(input),
+      }))
+
+      return api
+    },
+  }
+}
+
+export default createOpenService
diff --git a/services/open/test/service.test.ts b/services/open/test/service.test.ts
new file mode 100644
index 00000000..ff1857c6
--- /dev/null
+++ b/services/open/test/service.test.ts
@@ -0,0 +1,112 @@
+import type { DevframeHost } from 'devframe/types'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { createHostContext } from 'devframe/node'
+// `pathe` (not `node:path`) so the expected paths use the same normalized
+// forward-slash form the service resolves to on every OS.
+import { join } from 'pathe'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { createOpenService } from '../src/index'
+
+const launchEditor = vi.fn()
+const open = vi.fn()
+vi.mock('devframe/utils/launch-editor', () => ({ launchEditor: (...args: unknown[]) => launchEditor(...args) }))
+vi.mock('devframe/utils/open', () => ({ open: async (...args: unknown[]) => open(...args) }))
+
+const tempDirs: string[] = []
+
+afterEach(() => {
+  vi.clearAllMocks()
+  for (const dir of tempDirs.splice(0))
+    rmSync(dir, { recursive: true, force: true })
+})
+
+function createTestHost(dir: string): DevframeHost {
+  return {
+    mountStatic: () => {},
+    resolveOrigin: () => 'http://localhost',
+    getStorageDir: scope => join(dir, scope),
+  }
+}
+
+async function createCtx() {
+  const dir = mkdtempSync(join(tmpdir(), 'devframe-service-open-'))
+  tempDirs.push(dir)
+  const ctx = await createHostContext({ cwd: dir, mode: 'dev', host: createTestHost(dir) })
+  return { ctx, dir }
+}
+
+function invoke(ctx: Awaited>['ctx'], method: string, ...args: unknown[]) {
+  return (ctx.rpc.invokeLocal as (method: string, ...args: unknown[]) => Promise)(method, ...args)
+}
+
+describe('@devframes/service-open', () => {
+  it('registers scoped RPC and opens contained files with line/column', async () => {
+    const { ctx, dir } = await createCtx()
+    const install = ctx.services.install(createOpenService())
+    await ctx.services.ready()
+    const api = await install
+
+    await invoke(ctx, 'devframes:service:open:open-in-editor', { path: join(dir, 'src/a.ts'), line: 3, column: 7 })
+    expect(launchEditor).toHaveBeenCalledWith(`${join(dir, 'src/a.ts')}:3:7`, undefined)
+
+    await api!.openInFinder({ path: join(dir, 'src') })
+    expect(open).toHaveBeenCalledWith(join(dir, 'src'))
+  })
+
+  it('prefers the per-call editor over the merged option', async () => {
+    const { ctx, dir } = await createCtx()
+    void ctx.services.install(createOpenService({ editor: 'code' }))
+    await ctx.services.ready()
+
+    await invoke(ctx, 'devframes:service:open:open-in-editor', { path: join(dir, 'a.ts') })
+    expect(launchEditor).toHaveBeenLastCalledWith(join(dir, 'a.ts'), 'code')
+
+    await invoke(ctx, 'devframes:service:open:open-in-editor', { path: join(dir, 'a.ts'), editor: 'zed' })
+    expect(launchEditor).toHaveBeenLastCalledWith(join(dir, 'a.ts'), 'zed')
+  })
+
+  it('resolves relative paths against the workspace root', async () => {
+    const { ctx, dir } = await createCtx()
+    const install = ctx.services.install(createOpenService())
+    await ctx.services.ready()
+    const api = await install
+
+    await api!.openInEditor({ path: 'src/a.ts' })
+    expect(launchEditor).toHaveBeenCalledWith(join(dir, 'src/a.ts'), undefined)
+  })
+
+  it('refuses paths outside the allowed roots', async () => {
+    const { ctx } = await createCtx()
+    const install = ctx.services.install(createOpenService())
+    await ctx.services.ready()
+    const api = await install
+
+    await expect(api!.openInFinder({ path: '/etc/passwd' })).rejects.toThrowError(/outside the workspace root/)
+    expect(open).not.toHaveBeenCalled()
+  })
+
+  it('merges roots as a union so extra directories become openable', async () => {
+    const { ctx } = await createCtx()
+    const extra = mkdtempSync(join(tmpdir(), 'devframe-service-open-extra-'))
+    tempDirs.push(extra)
+    void ctx.services.install(createOpenService({ roots: [extra] }))
+    void ctx.services.install({ package: '@devframes/service-open', options: { editor: 'zed' } })
+    await ctx.services.ready()
+
+    await invoke(ctx, 'devframes:service:open:open-in-editor', { path: join(extra, 'b.ts') })
+    // Union kept the first installer's roots; later editor option won.
+    expect(launchEditor).toHaveBeenCalledWith(join(extra, 'b.ts'), 'zed')
+  })
+
+  it('rejects unknown editor commands at the RPC boundary', async () => {
+    const { ctx, dir } = await createCtx()
+    void ctx.services.install(createOpenService())
+    await ctx.services.ready()
+
+    await expect(
+      invoke(ctx, 'devframes:service:open:open-in-editor', { path: join(dir, 'a.ts'), editor: 'rm -rf /' }),
+    ).rejects.toThrow()
+    expect(launchEditor).not.toHaveBeenCalled()
+  })
+})
diff --git a/services/open/tsconfig.json b/services/open/tsconfig.json
new file mode 100644
index 00000000..25652292
--- /dev/null
+++ b/services/open/tsconfig.json
@@ -0,0 +1,9 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "lib": ["esnext", "dom"],
+    "types": ["node"]
+  },
+  "include": ["src", "test", "tsdown.config.ts"],
+  "exclude": ["dist", "node_modules"]
+}
diff --git a/services/open/tsdown.config.ts b/services/open/tsdown.config.ts
new file mode 100644
index 00000000..03714bc1
--- /dev/null
+++ b/services/open/tsdown.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'tsdown'
+
+export default defineConfig({
+  platform: 'node',
+  tsconfig: '../../tsconfig.base.json',
+  outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }),
+  entry: { index: 'src/index.ts' },
+})
diff --git a/services/shiki/package.json b/services/shiki/package.json
new file mode 100644
index 00000000..e8ac370a
--- /dev/null
+++ b/services/shiki/package.json
@@ -0,0 +1,50 @@
+{
+  "name": "@devframes/service-shiki",
+  "type": "module",
+  "version": "0.9.0",
+  "description": "Devframe wire service that renders Shiki syntax highlighting on the server, so plugins stop re-bundling highlighters.",
+  "author": "Anthony Fu ",
+  "license": "MIT",
+  "homepage": "https://github.com/devframes/devframe#readme",
+  "repository": {
+    "directory": "services/shiki",
+    "type": "git",
+    "url": "git+https://github.com/devframes/devframe.git"
+  },
+  "bugs": "https://github.com/devframes/devframe/issues",
+  "keywords": [
+    "devframe",
+    "devframe-service",
+    "devtools",
+    "shiki",
+    "syntax-highlighting"
+  ],
+  "sideEffects": false,
+  "exports": {
+    ".": "./dist/index.mjs",
+    "./package.json": "./package.json"
+  },
+  "types": "./dist/index.d.mts",
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsdown",
+    "watch": "tsdown --watch",
+    "prepack": "turbo run build --filter=@devframes/service-shiki",
+    "test": "vitest run",
+    "typecheck": "tsc --noEmit"
+  },
+  "peerDependencies": {
+    "devframe": "workspace:*"
+  },
+  "dependencies": {
+    "shiki": "catalog:deps"
+  },
+  "devDependencies": {
+    "@types/node": "catalog:types",
+    "devframe": "workspace:*",
+    "tsdown": "catalog:build",
+    "vitest": "catalog:testing"
+  }
+}
diff --git a/services/shiki/src/index.ts b/services/shiki/src/index.ts
new file mode 100644
index 00000000..bd2caa43
--- /dev/null
+++ b/services/shiki/src/index.ts
@@ -0,0 +1,192 @@
+import type { DevframeServiceDefinition } from 'devframe/types'
+import type { BundledLanguage, codeToHast, codeToTokens, SpecialLanguage } from 'shiki'
+import { defineRpcFunction } from 'devframe'
+import { hash } from 'devframe/utils/hash'
+import { s } from 'devframe/utils/simple-schema'
+import pkg from '../package.json' with { type: 'json' }
+
+export const SHIKI_SERVICE_PACKAGE = '@devframes/service-shiki'
+export const SHIKI_SERVICE_SCOPE = 'devframes:service:shiki'
+
+/** Dual light/dark theme pair, rendered via Shiki's dual-theme CSS variables. */
+export interface ShikiThemes {
+  light: string
+  dark: string
+}
+
+/** Defaults matching the `@antfu/design` light/dark surfaces. */
+export const SHIKI_DEFAULT_THEMES: ShikiThemes = { light: 'vitesse-light', dark: 'vitesse-dark' }
+
+export interface ShikiServiceOptions {
+  /**
+   * Theme pair every request uses unless it carries its own. On merge, the
+   * later installer's pair wins.
+   */
+  themes?: ShikiThemes
+  /**
+   * Languages to eagerly load at setup (others load on demand, per request).
+   * Merged as a union across installers.
+   */
+  langs?: string[]
+}
+
+export interface ShikiHighlightInput {
+  code: string
+  /** Language id; unknown ids degrade to plain text instead of throwing. */
+  lang?: string
+  /** Per-request theme override. */
+  themes?: ShikiThemes
+}
+
+export type ShikiHast = Awaited>
+export type ShikiTokens = Awaited>
+
+export interface ShikiServiceApi {
+  /** Highlight to HTML (dual-theme: light values inline, dark via `--shiki-dark` vars). */
+  highlight: (input: ShikiHighlightInput) => Promise<{ html: string }>
+  /** Highlight to a HAST tree, for surfaces that render their own DOM. */
+  codeToHast: (input: ShikiHighlightInput) => Promise
+  /** Highlight to themed tokens, for line-oriented renderers (e.g. diff views). */
+  codeToTokens: (input: ShikiHighlightInput) => Promise
+}
+
+declare module 'devframe' {
+  interface DevframeRpcServerFunctions {
+    'devframes:service:shiki:highlight': (input: ShikiHighlightInput) => Promise<{ html: string }>
+    'devframes:service:shiki:code-to-hast': (input: ShikiHighlightInput) => Promise
+    'devframes:service:shiki:code-to-tokens': (input: ShikiHighlightInput) => Promise
+  }
+  interface DevframeServicesRegistry {
+    '@devframes/service-shiki': ShikiServiceApi
+  }
+  interface DevframeServicesScopeRegistry {
+    '@devframes/service-shiki': 'devframes:service:shiki'
+  }
+}
+
+/** Tiny insertion-order LRU — enough to absorb re-renders of the same code. */
+class Lru {
+  private map = new Map()
+  constructor(private max: number) {}
+  get(key: string): V | undefined {
+    const value = this.map.get(key)
+    if (value !== undefined) {
+      this.map.delete(key)
+      this.map.set(key, value)
+    }
+    return value
+  }
+
+  set(key: string, value: V): void {
+    if (this.map.size >= this.max && !this.map.has(key))
+      this.map.delete(this.map.keys().next().value!)
+    this.map.set(key, value)
+  }
+}
+
+const inputSchema = s.object({
+  code: s.string(),
+  lang: s.optional(s.string()),
+  themes: s.optional(s.object({ light: s.string(), dark: s.string() })),
+})
+
+/**
+ * The Shiki wire service — server-side syntax highlighting shared by every
+ * plugin on the host, so client bundles stop shipping their own grammars and
+ * themes. Shiki itself loads lazily on first use; results are LRU-cached per
+ * `(code, lang, themes)` and every RPC function is `cacheable` on the client
+ * side too.
+ */
+export function createShikiService(options?: ShikiServiceOptions): DevframeServiceDefinition {
+  return {
+    package: SHIKI_SERVICE_PACKAGE,
+    version: pkg.version,
+    scope: SHIKI_SERVICE_SCOPE,
+    options,
+    // Option sets from multiple installers merge via devframe's default
+    // deep-merge: `langs` union, `themes` deep-merged (per-key last-wins).
+    setup(ctx, { options }) {
+      const defaultThemes = options?.themes ?? SHIKI_DEFAULT_THEMES
+
+      let shikiPromise: Promise | undefined
+      const shiki = () => shikiPromise ??= import('shiki').then(async (mod) => {
+        // Eagerly warm the declared languages alongside the default themes.
+        if (options?.langs?.length) {
+          await mod.getSingletonHighlighter({
+            langs: options.langs.filter(lang => lang in mod.bundledLanguages),
+            themes: [defaultThemes.light, defaultThemes.dark],
+          })
+        }
+        return mod
+      })
+
+      /** Unknown language ids degrade to plain text instead of throwing. */
+      async function resolveLang(lang: string | undefined): Promise {
+        if (!lang)
+          return 'text'
+        const mod = await shiki()
+        return lang in mod.bundledLanguages || ['text', 'plaintext', 'txt', 'plain', 'ansi'].includes(lang)
+          ? lang as BundledLanguage | SpecialLanguage
+          : 'text'
+      }
+
+      const cache = new Lru>(256)
+      function cached(kind: string, input: ShikiHighlightInput, compute: (lang: BundledLanguage | SpecialLanguage, themes: ShikiThemes) => Promise): Promise {
+        const themes = input.themes ?? defaultThemes
+        const key = hash([kind, input.lang, themes, input.code])
+        let result = cache.get(key) as Promise | undefined
+        if (!result) {
+          result = resolveLang(input.lang).then(lang => compute(lang, themes))
+          cache.set(key, result)
+        }
+        return result
+      }
+
+      // The spread turns the `ShikiThemes` interface into an object-literal
+      // type with an implicit index signature, as shiki's `themes` record
+      // requires.
+      const api: ShikiServiceApi = {
+        highlight: input => cached('html', input, async (lang, themes) =>
+          ({ html: await (await shiki()).codeToHtml(input.code, { lang, themes: { ...themes } }) })),
+        codeToHast: input => cached('hast', input, async (lang, themes) =>
+          (await shiki()).codeToHast(input.code, { lang, themes: { ...themes } })),
+        codeToTokens: input => cached('tokens', input, async (lang, themes) =>
+          (await shiki()).codeToTokens(input.code, { lang, themes: { ...themes } })),
+      }
+
+      // `s.object({})` is guard-only (extra keys survive) — a permissive
+      // envelope for the structured HAST / tokens payloads.
+      ctx.rpc.register(defineRpcFunction({
+        name: 'highlight',
+        type: 'query',
+        cacheable: true,
+        jsonSerializable: true,
+        args: [inputSchema],
+        returns: s.object({ html: s.string() }),
+        handler: (input: ShikiHighlightInput) => api.highlight(input),
+      }))
+      ctx.rpc.register(defineRpcFunction({
+        name: 'code-to-hast',
+        type: 'query',
+        cacheable: true,
+        jsonSerializable: true,
+        args: [inputSchema],
+        returns: s.object({}),
+        handler: (input: ShikiHighlightInput) => api.codeToHast(input),
+      }))
+      ctx.rpc.register(defineRpcFunction({
+        name: 'code-to-tokens',
+        type: 'query',
+        cacheable: true,
+        jsonSerializable: true,
+        args: [inputSchema],
+        returns: s.object({}),
+        handler: (input: ShikiHighlightInput) => api.codeToTokens(input),
+      }))
+
+      return api
+    },
+  }
+}
+
+export default createShikiService
diff --git a/services/shiki/test/service.test.ts b/services/shiki/test/service.test.ts
new file mode 100644
index 00000000..e27a1621
--- /dev/null
+++ b/services/shiki/test/service.test.ts
@@ -0,0 +1,88 @@
+import type { DevframeHost } from 'devframe/types'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { createHostContext } from 'devframe/node'
+import { afterEach, describe, expect, it } from 'vitest'
+import { createShikiService, SHIKI_DEFAULT_THEMES } from '../src/index'
+
+const tempDirs: string[] = []
+
+afterEach(() => {
+  for (const dir of tempDirs.splice(0))
+    rmSync(dir, { recursive: true, force: true })
+})
+
+function createTestHost(dir: string): DevframeHost {
+  return {
+    mountStatic: () => {},
+    resolveOrigin: () => 'http://localhost',
+    getStorageDir: scope => join(dir, scope),
+  }
+}
+
+async function createService(options?: Parameters[0]) {
+  const dir = mkdtempSync(join(tmpdir(), 'devframe-service-shiki-'))
+  tempDirs.push(dir)
+  const ctx = await createHostContext({ cwd: dir, mode: 'dev', host: createTestHost(dir) })
+  const install = ctx.services.install(createShikiService(options))
+  await ctx.services.ready()
+  return { ctx, api: (await install)! }
+}
+
+describe('@devframes/service-shiki', () => {
+  it('highlights with dual light/dark themes by default', async () => {
+    const { ctx, api } = await createService()
+    const { html } = await api.highlight({ code: 'const a = 1', lang: 'ts' })
+    expect(html).toContain(' Promise<{ html: string }>)(
+      'devframes:service:shiki:highlight',
+      { code: 'const a = 1', lang: 'ts' },
+    )
+    expect(viaRpc.html).toBe(html)
+  })
+
+  it('degrades unknown languages to plain text instead of throwing', async () => {
+    const { api } = await createService()
+    const { html } = await api.highlight({ code: 'hello world', lang: 'not-a-language' })
+    expect(html).toContain('hello world')
+  })
+
+  it('serves tokens and hast for renderers that own their DOM', async () => {
+    const { api } = await createService()
+    const tokens = await api.codeToTokens({ code: 'const a = 1', lang: 'ts' })
+    expect(tokens.tokens.length).toBeGreaterThan(0)
+    const hast = await api.codeToHast({ code: 'const a = 1', lang: 'ts' })
+    expect(hast.children.length).toBeGreaterThan(0)
+  })
+
+  it('caches per (code, lang, themes)', async () => {
+    const { api } = await createService()
+    const first = api.highlight({ code: 'let x = 2', lang: 'ts' })
+    const second = api.highlight({ code: 'let x = 2', lang: 'ts' })
+    expect(second).toBe(first) // same cached promise
+    const other = api.highlight({ code: 'let x = 2', lang: 'ts', themes: { light: 'github-light', dark: 'github-dark' } })
+    expect(other).not.toBe(first)
+    await expect(other).resolves.toHaveProperty('html')
+  })
+
+  it('merges options: later themes win, langs union', async () => {
+    const dir = mkdtempSync(join(tmpdir(), 'devframe-service-shiki-'))
+    tempDirs.push(dir)
+    const ctx = await createHostContext({ cwd: dir, mode: 'dev', host: createTestHost(dir) })
+    void ctx.services.install(createShikiService({ langs: ['ts'] }))
+    void ctx.services.install({
+      package: '@devframes/service-shiki',
+      options: { langs: ['vue'], themes: SHIKI_DEFAULT_THEMES },
+    })
+    await ctx.services.ready()
+    const api = ctx.services.get('@devframes/service-shiki')
+    const { html } = await api!.highlight({ code: 'const a = 1', lang: 'ts' })
+    expect(html).toContain('--shiki-dark')
+  })
+})
diff --git a/services/shiki/tsconfig.json b/services/shiki/tsconfig.json
new file mode 100644
index 00000000..25652292
--- /dev/null
+++ b/services/shiki/tsconfig.json
@@ -0,0 +1,9 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "lib": ["esnext", "dom"],
+    "types": ["node"]
+  },
+  "include": ["src", "test", "tsdown.config.ts"],
+  "exclude": ["dist", "node_modules"]
+}
diff --git a/services/shiki/tsdown.config.ts b/services/shiki/tsdown.config.ts
new file mode 100644
index 00000000..03714bc1
--- /dev/null
+++ b/services/shiki/tsdown.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'tsdown'
+
+export default defineConfig({
+  platform: 'node',
+  tsconfig: '../../tsconfig.base.json',
+  outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }),
+  entry: { index: 'src/index.ts' },
+})
diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md
index af2f4124..f9f5a368 100644
--- a/skills/devframe/SKILL.md
+++ b/skills/devframe/SKILL.md
@@ -558,7 +558,7 @@ Devframe re-exports a curated set of helpers under `devframe/utils/*`. They are
 | `createStreamSink` / `createStreamReader` from `devframe/utils/streaming-channel` | - | Low-level streaming primitives |
 | `evaluateWhen` / `WhenExpression` from `devframe/utils/when` | `whenexpr` | When-clause expressions |
 
-For "open file in editor" + "reveal in finder", prefer the prebuilt `commonRpcFunctions` RPC recipe (`devframe/recipes/common-rpc-functions`) - it wires the two utilities into named RPC functions ready to register.
+For "open file in editor" + "reveal in finder", prefer the `@devframes/service-open` wire service (declare `services: [{ package: '@devframes/service-open' }]` on the definition, gate client UI on `rpc.services.has(...)`) - one host-level installation shared by every plugin, with workspace-root path containment. The older `commonRpcFunctions` recipe (`devframe/recipes/common-rpc-functions`) still works but is deprecated.
 
 ## Security (secure by default)
 
diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts
index 6ca00cd0..b150074c 100644
--- a/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts
+++ b/tests/__snapshots__/tsnapi/@devframes/hub/initiate.snapshot.d.ts
@@ -37,6 +37,7 @@ export interface InitHubOptions {
   version?: string;
   base: string;
   devframes?: DevframesInput;
+  services?: DevframeServiceInput[];
   rpcDeclarations?: CreateHubContextOptions['builtinRpcDeclarations'];
   context?: DevframeHubContext;
   configure?: (_: DevframeHubContext) => void | Promise;
diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-assets/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-assets/index.snapshot.d.ts
index fca51204..84c2fe26 100644
--- a/tests/__snapshots__/tsnapi/@devframes/plugin-assets/index.snapshot.d.ts
+++ b/tests/__snapshots__/tsnapi/@devframes/plugin-assets/index.snapshot.d.ts
@@ -13,6 +13,7 @@ export interface AssetInfo {
   publicPath: string;
   size: number;
   mtime: number;
+  fsPath?: string;
 }
 export interface AssetsDevframeOptions {
   id?: string;
diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-assets/node.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-assets/node.snapshot.d.ts
index 1a6b0f4a..093fa445 100644
--- a/tests/__snapshots__/tsnapi/@devframes/plugin-assets/node.snapshot.d.ts
+++ b/tests/__snapshots__/tsnapi/@devframes/plugin-assets/node.snapshot.d.ts
@@ -18,6 +18,6 @@ export declare function disposeAssetsWatcher(_: DevframeNodeContext): Promise;
+export declare function scanAssets(_: string, _: string, _?: boolean): Promise;
 export declare function setupAssets(_: DevframeNodeContext, _: SetupAssetsOptions): Promise;
 // #endregion
\ No newline at end of file
diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-assets/rpc.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-assets/rpc.snapshot.d.ts
index ecff6104..2cef3b00 100644
--- a/tests/__snapshots__/tsnapi/@devframes/plugin-assets/rpc.snapshot.d.ts
+++ b/tests/__snapshots__/tsnapi/@devframes/plugin-assets/rpc.snapshot.d.ts
@@ -13,47 +13,20 @@ export interface RenameArgs {
 // #endregion
 
 // #region Variables
-export declare const alwaysFunctions: readonly [{
-  name: "devframes:plugin:assets:open-in-editor";
-  type?: "action" | undefined;
-  cacheable?: boolean;
-  args: readonly [import("devframe/utils/simple-schema").SimpleSchema];
-  returns: import("devframe/utils/simple-schema").SimpleSchema;
-  jsonSerializable?: boolean;
-  agent?: import("devframe").RpcFunctionAgentOptions;
-  setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined;
-  handler?: ((args_0: string) => import("devframe/rpc").Thenable) | undefined;
-  dump?: import("devframe/rpc").RpcDump<[string], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined;
-  snapshot?: boolean;
-  __cache?: WeakMap>>> | undefined;
-  __promise?: import("devframe/rpc").Thenable>> | undefined;
-}, {
-  name: "devframes:plugin:assets:reveal-in-folder";
-  type?: "action" | undefined;
-  cacheable?: boolean;
-  args: readonly [import("devframe/utils/simple-schema").SimpleSchema];
-  returns: import("devframe/utils/simple-schema").SimpleSchema;
-  jsonSerializable?: boolean;
-  agent?: import("devframe").RpcFunctionAgentOptions;
-  setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined;
-  handler?: ((args_0: string) => import("devframe/rpc").Thenable) | undefined;
-  dump?: import("devframe/rpc").RpcDump<[string], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined;
-  snapshot?: boolean;
-  __cache?: WeakMap>>> | undefined;
-  __promise?: import("devframe/rpc").Thenable>> | undefined;
-}];
 export declare const assetInfoSchema: import("devframe/utils/simple-schema").SimpleSchema<{
   path: string;
   type: "image" | "font" | "video" | "audio" | "text" | "other";
   publicPath: string;
   size: number;
   mtime: number;
+  fsPath?: string | undefined;
 }, {
   path: string;
   type: "image" | "font" | "video" | "audio" | "text" | "other";
   publicPath: string;
   size: number;
   mtime: number;
+  fsPath?: string | undefined;
 }>;
 export declare const capabilities: {
   name: "devframes:plugin:assets:capabilities";
@@ -145,12 +118,14 @@ export declare const list: {
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }[], {
     path: string;
     type: "image" | "font" | "video" | "audio" | "text" | "other";
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }[]>;
   jsonSerializable?: boolean;
   agent?: import("devframe").RpcFunctionAgentOptions;
@@ -160,6 +135,7 @@ export declare const list: {
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }[]>>>) | undefined;
   handler?: (() => import("devframe/rpc").Thenable<{
     path: string;
@@ -167,6 +143,7 @@ export declare const list: {
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }[]>) | undefined;
   dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable<{
     path: string;
@@ -174,6 +151,7 @@ export declare const list: {
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }[]>, DevframeNodeContext> | undefined;
   snapshot?: boolean;
   __cache?: WeakMap>>> | undefined;
   __promise?: import("devframe/rpc").Thenable>> | undefined;
 };
 export declare const mkdir: {
@@ -220,21 +200,6 @@ export declare const mkdir: {
     path: string;
   }], import("devframe/rpc").Thenable>> | undefined;
 };
-export declare const openInEditor: {
-  name: "devframes:plugin:assets:open-in-editor";
-  type?: "action" | undefined;
-  cacheable?: boolean;
-  args: readonly [import("devframe/utils/simple-schema").SimpleSchema];
-  returns: import("devframe/utils/simple-schema").SimpleSchema;
-  jsonSerializable?: boolean;
-  agent?: import("devframe").RpcFunctionAgentOptions;
-  setup?: ((context: DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined;
-  handler?: ((args_0: string) => import("devframe/rpc").Thenable) | undefined;
-  dump?: import("devframe/rpc").RpcDump<[string], import("devframe/rpc").Thenable, DevframeNodeContext> | undefined;
-  snapshot?: boolean;
-  __cache?: WeakMap>>> | undefined;
-  __promise?: import("devframe/rpc").Thenable>> | undefined;
-};
 export declare const readFunctions: readonly [{
   name: "devframes:plugin:assets:list";
   type?: "query" | undefined;
@@ -246,12 +211,14 @@ export declare const readFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }[], {
     path: string;
     type: "image" | "font" | "video" | "audio" | "text" | "other";
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }[]>;
   jsonSerializable?: boolean;
   agent?: import("devframe").RpcFunctionAgentOptions;
@@ -261,6 +228,7 @@ export declare const readFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }[]>>>) | undefined;
   handler?: (() => import("devframe/rpc").Thenable<{
     path: string;
@@ -268,6 +236,7 @@ export declare const readFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }[]>) | undefined;
   dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable<{
     path: string;
@@ -275,6 +244,7 @@ export declare const readFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }[]>, import("devframe").DevframeNodeContext> | undefined;
   snapshot?: boolean;
   __cache?: WeakMap>>> | undefined;
   __promise?: import("devframe/rpc").Thenable>> | undefined;
 }, {
   name: "devframes:plugin:assets:read-image-meta";
@@ -458,12 +430,14 @@ export declare const rename: {
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }, {
     path: string;
     type: "image" | "font" | "video" | "audio" | "text" | "other";
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }>;
   jsonSerializable?: boolean;
   agent?: import("devframe").RpcFunctionAgentOptions;
@@ -476,6 +450,7 @@ export declare const rename: {
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }>>>) | undefined;
   handler?: ((args_0: {
     path: string;
@@ -486,6 +461,7 @@ export declare const rename: {
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }>) | undefined;
   dump?: import("devframe/rpc").RpcDump<[{
     path: string;
@@ -496,6 +472,7 @@ export declare const rename: {
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }>, DevframeNodeContext> | undefined;
   snapshot?: boolean;
   __cache?: WeakMap>>> | undefined;
   __promise?: import("devframe/rpc").Thenable>> | undefined;
 };
-export declare const revealInFolder: {
-  name: "devframes:plugin:assets:reveal-in-folder";
-  type?: "action" | undefined;
-  cacheable?: boolean;
-  args: readonly [import("devframe/utils/simple-schema").SimpleSchema];
-  returns: import("devframe/utils/simple-schema").SimpleSchema;
-  jsonSerializable?: boolean;
-  agent?: import("devframe").RpcFunctionAgentOptions;
-  setup?: ((context: DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined;
-  handler?: ((args_0: string) => import("devframe/rpc").Thenable) | undefined;
-  dump?: import("devframe/rpc").RpcDump<[string], import("devframe/rpc").Thenable, DevframeNodeContext> | undefined;
-  snapshot?: boolean;
-  __cache?: WeakMap>>> | undefined;
-  __promise?: import("devframe/rpc").Thenable>> | undefined;
-};
 export declare const serverFunctions: readonly [{
   name: "devframes:plugin:assets:list";
   type?: "query" | undefined;
@@ -545,12 +509,14 @@ export declare const serverFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }[], {
     path: string;
     type: "image" | "font" | "video" | "audio" | "text" | "other";
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }[]>;
   jsonSerializable?: boolean;
   agent?: import("devframe").RpcFunctionAgentOptions;
@@ -560,6 +526,7 @@ export declare const serverFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }[]>>>) | undefined;
   handler?: (() => import("devframe/rpc").Thenable<{
     path: string;
@@ -567,6 +534,7 @@ export declare const serverFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }[]>) | undefined;
   dump?: import("devframe/rpc").RpcDump<[], import("devframe/rpc").Thenable<{
     path: string;
@@ -574,6 +542,7 @@ export declare const serverFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }[]>, import("devframe").DevframeNodeContext> | undefined;
   snapshot?: boolean;
   __cache?: WeakMap>>> | undefined;
   __promise?: import("devframe/rpc").Thenable>> | undefined;
 }, {
   name: "devframes:plugin:assets:read-image-meta";
@@ -681,34 +652,6 @@ export declare const serverFunctions: readonly [{
     write: boolean;
     uploadExtensions: string[] | "*";
   }>>> | undefined;
-}, {
-  name: "devframes:plugin:assets:open-in-editor";
-  type?: "action" | undefined;
-  cacheable?: boolean;
-  args: readonly [import("devframe/utils/simple-schema").SimpleSchema];
-  returns: import("devframe/utils/simple-schema").SimpleSchema;
-  jsonSerializable?: boolean;
-  agent?: import("devframe").RpcFunctionAgentOptions;
-  setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined;
-  handler?: ((args_0: string) => import("devframe/rpc").Thenable) | undefined;
-  dump?: import("devframe/rpc").RpcDump<[string], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined;
-  snapshot?: boolean;
-  __cache?: WeakMap>>> | undefined;
-  __promise?: import("devframe/rpc").Thenable>> | undefined;
-}, {
-  name: "devframes:plugin:assets:reveal-in-folder";
-  type?: "action" | undefined;
-  cacheable?: boolean;
-  args: readonly [import("devframe/utils/simple-schema").SimpleSchema];
-  returns: import("devframe/utils/simple-schema").SimpleSchema;
-  jsonSerializable?: boolean;
-  agent?: import("devframe").RpcFunctionAgentOptions;
-  setup?: ((context: import("devframe").DevframeNodeContext) => import("devframe/rpc").Thenable>>) | undefined;
-  handler?: ((args_0: string) => import("devframe/rpc").Thenable) | undefined;
-  dump?: import("devframe/rpc").RpcDump<[string], import("devframe/rpc").Thenable, import("devframe").DevframeNodeContext> | undefined;
-  snapshot?: boolean;
-  __cache?: WeakMap>>> | undefined;
-  __promise?: import("devframe/rpc").Thenable>> | undefined;
 }, {
   name: "devframes:plugin:assets:upload";
   type?: "action" | undefined;
@@ -768,12 +711,14 @@ export declare const serverFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }, {
     path: string;
     type: "image" | "font" | "video" | "audio" | "text" | "other";
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }>;
   jsonSerializable?: boolean;
   agent?: import("devframe").RpcFunctionAgentOptions;
@@ -786,6 +731,7 @@ export declare const serverFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }>>>) | undefined;
   handler?: ((args_0: {
     path: string;
@@ -796,6 +742,7 @@ export declare const serverFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }>) | undefined;
   dump?: import("devframe/rpc").RpcDump<[{
     path: string;
@@ -806,6 +753,7 @@ export declare const serverFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }>, import("devframe").DevframeNodeContext> | undefined;
   snapshot?: boolean;
   __cache?: WeakMap>>> | undefined;
   __promise?: import("devframe/rpc").Thenable>> | undefined;
 }, {
   name: "devframes:plugin:assets:delete";
@@ -1002,12 +952,14 @@ export declare const writeFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }, {
     path: string;
     type: "image" | "font" | "video" | "audio" | "text" | "other";
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }>;
   jsonSerializable?: boolean;
   agent?: import("devframe").RpcFunctionAgentOptions;
@@ -1020,6 +972,7 @@ export declare const writeFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }>>>) | undefined;
   handler?: ((args_0: {
     path: string;
@@ -1030,6 +983,7 @@ export declare const writeFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }>) | undefined;
   dump?: import("devframe/rpc").RpcDump<[{
     path: string;
@@ -1040,6 +994,7 @@ export declare const writeFunctions: readonly [{
     publicPath: string;
     size: number;
     mtime: number;
+    fsPath?: string | undefined;
   }>, import("devframe").DevframeNodeContext> | undefined;
   snapshot?: boolean;
   __cache?: WeakMap>>> | undefined;
   __promise?: import("devframe/rpc").Thenable>> | undefined;
 }, {
   name: "devframes:plugin:assets:delete";
diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-assets/rpc.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/plugin-assets/rpc.snapshot.js
index 5a3e1580..bd965a55 100644
--- a/tests/__snapshots__/tsnapi/@devframes/plugin-assets/rpc.snapshot.js
+++ b/tests/__snapshots__/tsnapi/@devframes/plugin-assets/rpc.snapshot.js
@@ -2,18 +2,15 @@
  * Generated by tsnapi — public API snapshot of `@devframes/plugin-assets/rpc`
  */
 // #region Other
-export { alwaysFunctions }
 export { assetInfoSchema }
 export { capabilities }
 export { deleteAssets }
 export { list }
 export { mkdir }
-export { openInEditor }
 export { readFunctions }
 export { readImageMeta }
 export { readText }
 export { rename }
-export { revealInFolder }
 export { serverFunctions }
 export { upload }
 export { UPLOAD_CHANNEL }
diff --git a/tests/__snapshots__/tsnapi/@devframes/service-open/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/service-open/index.snapshot.d.ts
new file mode 100644
index 00000000..daabb332
--- /dev/null
+++ b/tests/__snapshots__/tsnapi/@devframes/service-open/index.snapshot.d.ts
@@ -0,0 +1,35 @@
+/**
+ * Generated by tsnapi — public API snapshot of `@devframes/service-open`
+ */
+// #region Interfaces
+export interface OpenInEditorInput {
+  path: string;
+  line?: number;
+  column?: number;
+  editor?: KnownEditor;
+}
+export interface OpenServiceApi {
+  openInEditor: (_: OpenInEditorInput) => Promise;
+  openInFinder: (_: {
+    path: string;
+  }) => Promise;
+}
+export interface OpenServiceOptions {
+  editor?: KnownEditor;
+  roots?: string[];
+}
+// #endregion
+
+// #region Functions
+export declare function createOpenService(_?: OpenServiceOptions): DevframeServiceDefinition;
+// #endregion
+
+// #region Variables
+export declare const OPEN_SERVICE_PACKAGE: string;
+export declare const OPEN_SERVICE_SCOPE: string;
+// #endregion
+
+// #region Default Export
+declare function _default(_?: OpenServiceOptions): DevframeServiceDefinition;
+export default _default
+// #endregion
\ No newline at end of file
diff --git a/tests/__snapshots__/tsnapi/@devframes/service-open/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/service-open/index.snapshot.js
new file mode 100644
index 00000000..36384b42
--- /dev/null
+++ b/tests/__snapshots__/tsnapi/@devframes/service-open/index.snapshot.js
@@ -0,0 +1,16 @@
+/**
+ * Generated by tsnapi — public API snapshot of `@devframes/service-open`
+ */
+// #region Functions
+export function createOpenService(_) {}
+// #endregion
+
+// #region Variables
+export var OPEN_SERVICE_PACKAGE /* const */
+export var OPEN_SERVICE_SCOPE /* const */
+// #endregion
+
+// #region Default Export
+function _default(_) {}
+export default _default
+// #endregion
\ No newline at end of file
diff --git a/tests/__snapshots__/tsnapi/@devframes/service-shiki/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/service-shiki/index.snapshot.d.ts
new file mode 100644
index 00000000..bd554349
--- /dev/null
+++ b/tests/__snapshots__/tsnapi/@devframes/service-shiki/index.snapshot.d.ts
@@ -0,0 +1,45 @@
+/**
+ * Generated by tsnapi — public API snapshot of `@devframes/service-shiki`
+ */
+// #region Interfaces
+export interface ShikiHighlightInput {
+  code: string;
+  lang?: string;
+  themes?: ShikiThemes;
+}
+export interface ShikiServiceApi {
+  highlight: (_: ShikiHighlightInput) => Promise<{
+    html: string;
+  }>;
+  codeToHast: (_: ShikiHighlightInput) => Promise;
+  codeToTokens: (_: ShikiHighlightInput) => Promise;
+}
+export interface ShikiServiceOptions {
+  themes?: ShikiThemes;
+  langs?: string[];
+}
+export interface ShikiThemes {
+  light: string;
+  dark: string;
+}
+// #endregion
+
+// #region Types
+export type ShikiHast = Awaited>;
+export type ShikiTokens = Awaited>;
+// #endregion
+
+// #region Functions
+export declare function createShikiService(_?: ShikiServiceOptions): DevframeServiceDefinition;
+// #endregion
+
+// #region Variables
+export declare const SHIKI_DEFAULT_THEMES: ShikiThemes;
+export declare const SHIKI_SERVICE_PACKAGE: string;
+export declare const SHIKI_SERVICE_SCOPE: string;
+// #endregion
+
+// #region Default Export
+declare function _default(_?: ShikiServiceOptions): DevframeServiceDefinition;
+export default _default
+// #endregion
\ No newline at end of file
diff --git a/tests/__snapshots__/tsnapi/@devframes/service-shiki/index.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/service-shiki/index.snapshot.js
new file mode 100644
index 00000000..8c2e84f1
--- /dev/null
+++ b/tests/__snapshots__/tsnapi/@devframes/service-shiki/index.snapshot.js
@@ -0,0 +1,17 @@
+/**
+ * Generated by tsnapi — public API snapshot of `@devframes/service-shiki`
+ */
+// #region Functions
+export function createShikiService(_) {}
+// #endregion
+
+// #region Variables
+export var SHIKI_DEFAULT_THEMES /* const */
+export var SHIKI_SERVICE_PACKAGE /* const */
+export var SHIKI_SERVICE_SCOPE /* const */
+// #endregion
+
+// #region Default Export
+function _default(_) {}
+export default _default
+// #endregion
\ No newline at end of file
diff --git a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts
index 226c9c74..c8332b4e 100644
--- a/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts
+++ b/tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts
@@ -341,12 +341,6 @@ export declare const diagnostics: import("nostics").Diagnostics<{
     }) => string;
     readonly fix: "A service package's default export must be a factory returning a `DevframeServiceDefinition` — an object with `package`, `version`, `scope`, and a `setup` function.";
   };
-  readonly DF0071: {
-    readonly why: (p: {
-      reason: string;
-    }) => string;
-    readonly fix: "Call `ctx.services.ready()` explicitly after every devframe's setup has run (the first-party adapters do) so installation errors surface at startup instead of at connect time.";
-  };
 }, readonly [(d: import("nostics").Diagnostic, { method }?: {
   method?: "log" | "warn" | "error";
 }) => void]>;
diff --git a/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.d.ts b/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.d.ts
index e16219a9..bd3b8bd4 100644
--- a/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.d.ts
+++ b/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.d.ts
@@ -6,6 +6,7 @@ export type KnownEditor = 'atom' | 'subl' | 'sublime' | 'sublime_text' | 'wstorm
 // #endregion
 
 // #region Variables
+/** @deprecated */
 export declare const commonRpcFunctions: readonly [{
   name: "devframe:open-in-editor";
   type?: "action" | undefined;
@@ -36,6 +37,7 @@ export declare const commonRpcFunctions: readonly [{
   __promise?: Thenable>> | undefined;
 }];
 export declare const KNOWN_EDITORS: KnownEditor[];
+/** @deprecated */
 export declare const openInEditor: {
   name: "devframe:open-in-editor";
   type?: "action" | undefined;
@@ -51,6 +53,7 @@ export declare const openInEditor: {
   __cache?: WeakMap>>> | undefined;
   __promise?: Thenable>> | undefined;
 };
+/** @deprecated */
 export declare const openInFinder: {
   name: "devframe:open-in-finder";
   type?: "action" | undefined;
diff --git a/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.js b/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.js
index 244b9591..3b2ac529 100644
--- a/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.js
+++ b/tests/__snapshots__/tsnapi/devframe/recipes/common-rpc-functions.snapshot.js
@@ -2,8 +2,11 @@
  * Generated by tsnapi — public API snapshot of `devframe/recipes/common-rpc-functions`
  */
 // #region Variables
+/** @deprecated */
 export var commonRpcFunctions /* const */
 export var KNOWN_EDITORS /* const */
+/** @deprecated */
 export var openInEditor /* const */
+/** @deprecated */
 export var openInFinder /* const */
 // #endregion
\ No newline at end of file
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 62e621e2..42c821d2 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -378,6 +378,12 @@
       ],
       "@devframes/plugin-assets": [
         "./plugins/assets/src/index.ts"
+      ],
+      "@devframes/service-open": [
+        "./services/open/src/index.ts"
+      ],
+      "@devframes/service-shiki": [
+        "./services/shiki/src/index.ts"
       ]
     },
     "resolveJsonModule": true,
diff --git a/turbo.json b/turbo.json
index 3a7ae368..19208643 100644
--- a/turbo.json
+++ b/turbo.json
@@ -52,6 +52,16 @@
       "dependsOn": ["@devframes/json-render#build"],
       "outputs": ["dist/**"]
     },
+    "@devframes/service-open#build": {
+      "outputLogs": "new-only",
+      "dependsOn": ["devframe#build"],
+      "outputs": ["dist/**"]
+    },
+    "@devframes/service-shiki#build": {
+      "outputLogs": "new-only",
+      "dependsOn": ["devframe#build"],
+      "outputs": ["dist/**"]
+    },
     "@devframes/plugin-code-server#build": {
       "outputLogs": "new-only",
       "dependsOn": ["devframe#build", "@devframes/vite#build"],
diff --git a/vitest.config.ts b/vitest.config.ts
index fe703b86..c0f6a16f 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -29,6 +29,8 @@ export default defineConfig({
       'plugins/a11y',
       'plugins/messages',
       'plugins/assets',
+      'services/open',
+      'services/shiki',
       'examples/hub-next',
       'packages/next',
       'packages/vite',