Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Ahead-of-time build artifacts that live under `src/` - the shadow-root styleshee
## Conventions

- RPC functions must use `defineRpcFunction`; always namespace IDs `devframes:plugin:<slug>:<fn-name>` (matching the plugin's `@devframes/plugin-<slug>` package name).
- **No magic event names — use the centralized event maps.** Every event, broadcast, shared-state key, and channel name lives in one of two source-of-truth maps: `DEVFRAME_EVENTS` (`packages/devframe/src/events.ts`, re-exported from `devframe/constants`) for the core runtime, and `HUB_EVENTS` (`packages/hub/src/events.ts`, re-exported from `@devframes/hub/constants`) for the hub. Reference `DEVFRAME_EVENTS.*` / `HUB_EVENTS.*` at call sites (`.events.emit`/`.on`, `rpc.broadcast({ method })`, `sharedState.get(key)`, `defineHubRpcFunction({ name })`, `rpc.call`) instead of re-typing a string literal. The two maps and the [`docs/guide/events.md`](docs/guide/events.md) Events Reference are kept in lockstep: adding, renaming, or removing a name means editing the map **and** that page in the same change — every name in the maps appears in the tables, and vice versa. The only literals left are unavoidable type-position keys (the `EventEmitter<…>` maps in `types/*` and the `DevframeRpcClientFunctions`/`DevframeRpcServerFunctions` augmentations), which mirror the maps; a package that deliberately avoids a hub dependency (e.g. `@devframes/plugin-terminals`, which models the hub bridge structurally) keeps a local literal rather than importing `HUB_EVENTS`.
- **Stay validator-neutral.** `devframe` and every `@devframes/*` package must not introduce a preferred schema validator dependency - no `valibot`, `zod`, `arktype`, etc. in their runtime `dependencies`. `args`/`returns`/flag schemas are typed against [Standard Schema](https://standardschema.dev/) (`@standard-schema/spec`, types-only); first-party code that needs to author a schema uses the built-in zero-dep `devframe/utils/simple-schema` builder (deliberately minimal - not a general validator). JSON-schema conversion uses each schema's own Standard JSON Schema converter (`~standard.jsonSchema`, implemented by e.g. zod 4) when present and degrades to a permissive object otherwise - no converter library and no vendor dependency is required. Docs, by contrast, should point *users* at a real validator for their own integrations - recommend **valibot** (lightest) or **zod** (worth reusing if they already pull it via the JSON-render or MCP integrations).
- Shared state via `devframe/utils/shared-state`; keep values serializable.
- Utility imports use the package-path form `devframe/utils/*`, never relative `../utils/*`.
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ function guideGroups(prefix: string) {
{ text: 'Serve a Hub Anywhere', link: `${prefix}/guide/hub-initiate` },
{ text: 'Cross-Plugin Services', link: `${prefix}/guide/services` },
{ text: 'Deep Linking', link: `${prefix}/guide/deep-linking` },
{ text: 'Events Reference', link: `${prefix}/guide/events` },
],
},
{
Expand Down
104 changes: 104 additions & 0 deletions docs/guide/events.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
---
outline: deep
---

# Events Reference

Devframe carries change notifications across a few distinct channels. What separates them is **direction and reach**: an in-process event bus that never leaves the node process, server RPC methods a client calls, and server-pushed broadcasts and shared state a client reads.

Two naming prefixes mark the wire surface: `hub:` for hub-layer server RPC (client → server actions), and `devframe:` for the client-facing devframe protocol (broadcasts, shared state, and streams pushed server → client). The internal event bus mirrors the same plural subsystem vocabulary (`docks`, `terminals`, `messages`, `commands`), so each internal event lines up with its wire counterpart — `docks:activate` fans out to `devframe:docks:activate`.

Every name on this page has one home in code: the [`HUB_EVENTS`](https://github.com/devframes/devframe/blob/main/packages/hub/src/events.ts) map (`@devframes/hub/constants`) backs the hub tables, and the [`DEVFRAME_EVENTS`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/events.ts) map (`devframe/constants`) backs the core tables. Call sites reference `HUB_EVENTS.*` / `DEVFRAME_EVENTS.*` rather than re-typing a literal, and this page and those maps move together — changing one without the other is a bug.

## Hub events

### Internal node event bus

Each subsystem host emits on `ctx.<subsystem>.events`. These fire and are consumed **inside the same node process** — chiefly by `createHubContext`, which fans them out onto the wire. They never cross to the browser.

| Event | Emitted by | Consumed by | Payload |
|---|---|---|---|
| `docks:entry:updated` | `DocksHost.register` / `update` | context → `devframe:docks` shared state | `DevframeDockUserEntry` |
| `docks:activate` | `DocksHost.activate()` | context → broadcast + `devframe:docks:active` | `DevframeDockActivation` |
| `terminals:session:updated` | `TerminalsHost` register / update / remove / status change | context → `devframe:terminals:updated`; terminals plugin | `DevframeTerminalSession` |
| `messages:added` / `messages:updated` / `messages:removed` / `messages:cleared` | `MessagesHost` mutations | context → `devframe:messages:updated`; messages plugin | entry / entry / id / — |
| `commands:registered` / `commands:unregistered` | `CommandsHost` register / update / unregister | context → `devframe:commands` shared state | entry / id |

The `docks:entry:updated` and `terminals:session:updated` middle nouns (`entry`, `session`) name the specific record type; the messages and commands subsystems imply their record in the subsystem name, so they carry the verb directly.

### Server RPC methods — client → server

A connected client (any mounted iframe or panel, on its own RPC client) calls these; the hub node handles them. Carry the `hub:` prefix.

| Method | Signature | Purpose |
|---|---|---|
| `hub:docks:activate` | `({ dockId, params? }) => void` | Ask the viewer to switch its active dock — see [Deep Linking](./deep-linking). |
| `hub:commands:execute` | `(id, ...args) => unknown` | Invoke a registered server command by id. |
| `hub:messages:add` | `(input) => DevframeMessageEntry` | Add a message to the feed (marked `from: 'browser'`). |
| `hub:messages:update` | `(id, patch) => DevframeMessageEntry \| undefined` | Patch a message by id. |
| `hub:messages:remove` | `(id) => void` | Remove a message by id. |
| `hub:messages:clear` | `() => void` | Remove every message. |
| `hub:terminals:write` | `(id, data) => void` | Send input to an interactive PTY session. |
| `hub:terminals:resize` | `(id, cols, rows) => void` | Resize an interactive PTY session. |
| `hub:terminals:terminate` | `(id) => void` | Kill a session's process, keeping it registered. |
| `hub:terminals:restart` | `(id) => void` | Re-run a session's command in place. |
| `hub:terminals:remove` | `(id) => void` | Kill a session's process and drop it from the registry. |

### Broadcasts & shared state — server → client

The server pushes these; a hub-aware client reads or subscribes. Carry the `devframe:` prefix. A UI subscribes to broadcasts via `rpc.client.register(...)`; the [client host](./client-context) registers the `devframe:docks:activate` handler for you.

| Name | Kind | Carries |
|---|---|---|
| `devframe:docks:activate` | broadcast | Live "switch active dock" request — the client host calls its local `switchEntry`. |
| `devframe:terminals:updated` | broadcast | Terminal sessions changed; re-read terminal state. |
| `devframe:messages:updated` | broadcast | Message list changed; re-read message state. |
| `devframe:docks` | shared state | Projected dock entry list (`DevframeDockEntry[]`). |
| `devframe:docks:active` | shared state | Most recent `DevframeDockActivation`, so a dock that mounts in response still converges on it. |
| `devframe:commands` | shared state | Serializable command list, handlers stripped (`DevframeServerCommandEntry[]`). |
| `devframe:user-settings` | shared state | Persisted per-workspace hub settings (`DevframeDocksUserSettings`). |
| `devframe:terminals` | streaming channel | Live terminal output stream, keyed by session id. |

The [`devframe:docks:active`](./shared-state) mirror pairs with the `devframe:docks:activate` broadcast: the broadcast reaches docks already on screen, while the mirror lets a dock that mounts *because* of the switch converge on the same request instead of missing it.

## Core devframe events

The core `devframe` runtime (below the hub) carries its own notification channels — the agent host's change events, the client connection lifecycle, and the server-pushed broadcasts that power shared state and streaming. These are backed by `DEVFRAME_EVENTS` (`devframe/constants`).

This map covers notifications only. The request/response RPC endpoints of the shared-state, streaming, and auth-handshake protocols (`devframe:rpc:server-state:*`, `devframe:streaming:subscribe`, `anonymous:devframe:auth`, …) are defined at their handlers and typed in `types/rpc-augments.ts` — they aren't events.

### Node host bus

Emitted on `ctx.agent.events` as the agent-exposed tool/resource surface changes; protocol adapters (e.g. the MCP server) subscribe to re-publish their manifest.

| Event | Emitted by | Payload |
|---|---|---|
| `agent:manifest:changed` | any tool/resource/provider change | — |
| `agent:tool:registered` / `agent:tool:unregistered` | `registerTool` / `unregisterTool` | `AgentTool` / id |
| `agent:resource:registered` / `agent:resource:unregistered` | `registerResource` / `unregisterResource` | `AgentResource` / id |

### Client connection events

Emitted on the RPC client's `rpc.events` emitter (`RpcClientEvents`) for a UI to track connection lifecycle and surface errors.

| Event | Carries |
|---|---|
| `rpc:is-trusted:updated` | Trust gate flipped (`boolean`). |
| `rpc:error` | An RPC call rejected (`error`, `method`). |
| `connection:status` | Connection status changed (`status`, `previous`). |
| `connection:error` | A connection-level error (WebSocket errored, or trust refused). |

### Broadcasts — server → client

Pushed from the server to subscribed clients over the `devframe:` protocol. Wired by the framework's own hosts; not registered manually.

| Name | Carries |
|---|---|
| `devframe:auth:revoked` | This connection's bearer token was revoked; the client drops to untrusted. |
| `devframe:rpc:client-state:updated` | Full shared-state snapshot for a key. |
| `devframe:rpc:client-state:patch` | Incremental shared-state patch for a key. |
| `devframe:streaming:chunk` | A streaming chunk for a subscribed channel/id. |
| `devframe:streaming:end` | A streaming terminator (optionally an error). |
| `devframe:streaming:upload-cancel` | Server-side cancel of an in-flight upload. |

Plus one `postMessage` channel, `devframe:remote-assets-error`, that the remote-assets fallback page posts to `window.parent` so an embedding viewer can replace the bare 502 page with its own UI.
2 changes: 1 addition & 1 deletion docs/guide/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ A hub-aware UI doesn't import any hub classes; it reads three shared-state keys
| `hub:commands:execute` RPC | `(id, ...args) => unknown` | Server-side command dispatch. |
| `hub:docks:activate` RPC | `({ dockId, params? }) => void` | Switch the active dock from any client. |

Plus broadcast notifications (`devframe:docks:activate`, `devframe:terminals:updated`, `devframe:messages:updated`) that a UI can subscribe to via `rpc.client.register(...)`. The client host registers the `devframe:docks:activate` handler for you.
Plus broadcast notifications (`devframe:docks:activate`, `devframe:terminals:updated`, `devframe:messages:updated`) that a UI can subscribe to via `rpc.client.register(...)`. The client host registers the `devframe:docks:activate` handler for you. The [Events Reference](./events) tables every channel across all four subsystems.

## Running plugin code in the host page

Expand Down
3 changes: 2 additions & 1 deletion packages/devframe/src/adapters/mcp/build-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Server } from '@modelcontextprotocol/server'
import { createHostContext } from 'devframe/node'
import { toAgentToolName } from 'devframe/utils/agent-tool-name'
import { join } from 'pathe'
import { DEVFRAME_EVENTS } from '../../events'
import { diagnostics } from '../../node/diagnostics'
import { formatMcpError, stringifyForMcp } from './stringify'
import { argsToJsonSchema, returnToJsonSchema } from './to-json-schema'
Expand Down Expand Up @@ -70,7 +71,7 @@ export function buildMcpServerFromContext(
const notify = (method: string): void => {
server.notification({ method }).catch(() => { /* ignore transport errors */ })
}
const offManifest = ctx.agent.events.on('agent:manifest:changed', () => {
const offManifest = ctx.agent.events.on(DEVFRAME_EVENTS.bus.agentManifestChanged, () => {
notify('notifications/tools/list_changed')
notify('notifications/resources/list_changed')
})
Expand Down
27 changes: 14 additions & 13 deletions packages/devframe/src/client/rpc-live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ConnectionMeta, DevframeRpcClientFunctions, DevframeRpcServerFunct
import type { DevframeConnectionStatus } from './connection'
import type { DevframeClientRpcHost, DevframeRpcClientMode, DevframeRpcClientOptions, RpcClientEvents } from './rpc'
import { createRpcClient } from 'devframe/rpc/client'
import { DEVFRAME_EVENTS } from '../events'
import { promiseWithResolver } from '../utils/promise'
import { DevframeConnectionError } from './connection'

Expand Down Expand Up @@ -65,7 +66,7 @@ export function createLiveRpcClientMode(
return
const previous = status
status = next
events.emit('connection:status', next, previous)
events.emit(DEVFRAME_EVENTS.client.connectionStatus, next, previous)
}

// Pending calls we can settle proactively — a connection that drops (or a
Expand Down Expand Up @@ -99,7 +100,7 @@ export function createLiveRpcClientMode(
if (settled)
return
finish()
events.emit('rpc:error', error, method)
events.emit(DEVFRAME_EVENTS.client.error, error, method)
reject(error)
},
}
Expand Down Expand Up @@ -127,7 +128,7 @@ export function createLiveRpcClientMode(
return
finish()
const err = error instanceof Error ? error : new Error(String(error))
events.emit('rpc:error', err, method)
events.emit(DEVFRAME_EVENTS.client.error, err, method)
reject(err)
},
)
Expand All @@ -148,7 +149,7 @@ export function createLiveRpcClientMode(
definitions,
onError(error) {
setStatus('error', error)
events.emit('connection:error', error)
events.emit(DEVFRAME_EVENTS.client.connectionError, error)
rejectAllPending(new DevframeConnectionError('connection', '[devframe] Connection to the devframe server failed', { cause: error }))
},
onDisconnected() {
Expand All @@ -169,15 +170,15 @@ export function createLiveRpcClientMode(

// Handle server-initiated auth revocation
clientRpc.register({
name: 'devframe:auth:revoked',
name: DEVFRAME_EVENTS.broadcast.authRevoked,
type: 'event',
handler: () => {
isTrusted = false
const authError = new DevframeConnectionError('auth', '[devframe] The devframe server revoked this client\'s trust')
setStatus('unauthorized', authError)
events.emit('connection:error', authError)
events.emit(DEVFRAME_EVENTS.client.connectionError, authError)
rejectAllPending(authError)
events.emit('rpc:is-trusted:updated', false)
events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, false)
},
})

Expand Down Expand Up @@ -209,9 +210,9 @@ export function createLiveRpcClientMode(
// so it never lands here.
const authError = new DevframeConnectionError('auth', '[devframe] The devframe server refused this client\'s credentials')
setStatus('unauthorized', authError)
events.emit('connection:error', authError)
events.emit(DEVFRAME_EVENTS.client.connectionError, authError)
}
events.emit('rpc:is-trusted:updated', isTrusted)
events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, isTrusted)
return result.isTrusted
}

Expand All @@ -228,7 +229,7 @@ export function createLiveRpcClientMode(
isTrusted = true
trustedPromise.resolve(true)
setStatus('connected')
events.emit('rpc:is-trusted:updated', true)
events.emit(DEVFRAME_EVENTS.client.isTrustedUpdated, true)
}
return token
}
Expand Down Expand Up @@ -284,7 +285,7 @@ export function createLiveRpcClientMode(
const method = String(args[0])
const failFast = terminalError()
if (failFast) {
events.emit('rpc:error', failFast, method)
events.emit(DEVFRAME_EVENTS.client.error, failFast, method)
return Promise.reject(failFast)
}
return guardCall(
Expand All @@ -300,7 +301,7 @@ export function createLiveRpcClientMode(
// to send, so surface the failure and drop it instead of queuing forever.
const failFast = terminalError()
if (failFast) {
events.emit('rpc:error', failFast, String(args[0]))
events.emit(DEVFRAME_EVENTS.client.error, failFast, String(args[0]))
return
}
return serverRpc.$callEvent(
Expand All @@ -312,7 +313,7 @@ export function createLiveRpcClientMode(
const method = String(args[0])
const failFast = terminalError()
if (failFast) {
events.emit('rpc:error', failFast, method)
events.emit(DEVFRAME_EVENTS.client.error, failFast, method)
return Promise.reject(failFast)
}
return guardCall(
Expand Down
7 changes: 4 additions & 3 deletions packages/devframe/src/client/rpc-shared-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { RpcSharedStateGetOptions, RpcSharedStateHost } from 'devframe/type
import type { SharedState, SharedStatePatch } from 'devframe/utils/shared-state'
import type { DevframeRpcClient } from './rpc'
import { createSharedState } from 'devframe/utils/shared-state'
import { DEVFRAME_EVENTS } from '../events'

export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcSharedStateHost {
const sharedState = new Map<string, SharedState<any>>()
Expand All @@ -20,7 +21,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
}

rpc.client.register({
name: 'devframe:rpc:client-state:updated',
name: DEVFRAME_EVENTS.broadcast.clientStateUpdated,
type: 'event',
handler: (key: string, fullState: any, syncId: string) => {
const state = sharedState.get(key)
Expand All @@ -31,7 +32,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
})

rpc.client.register({
name: 'devframe:rpc:client-state:patch',
name: DEVFRAME_EVENTS.broadcast.clientStatePatch,
type: 'event',
handler: (key: string, patches: SharedStatePatch[], syncId: string) => {
const state = sharedState.get(key)
Expand Down Expand Up @@ -124,7 +125,7 @@ export function createRpcSharedStateClientHost(rpc: DevframeRpcClient): RpcShare
if (!rpc.isTrusted) {
resolve(state)
let initialized = false
rpc.events.on('rpc:is-trusted:updated', (isTrusted) => {
rpc.events.on(DEVFRAME_EVENTS.client.isTrustedUpdated, (isTrusted) => {
if (isTrusted && !initialized) {
initialized = true
initSharedState()
Expand Down
Loading
Loading