diff --git a/examples/a11y-messages-playground/README.md b/examples/a11y-messages-playground/README.md index c95b80db..6053a95e 100644 --- a/examples/a11y-messages-playground/README.md +++ b/examples/a11y-messages-playground/README.md @@ -78,7 +78,7 @@ the focused dock — the same path a manual dock click takes. | File | Role | |---|---| -| `src/a11y-messages-playground.ts` | The Vite host — hub context, static + connection-meta mounts, side-car WS | +| `src/a11y-messages-playground.ts` | The Vite host — hub context, static + connection-meta mounts, side-car WS, instance-registry registration | | `vite.config.ts` | Mounts a11y + messages; attaches the a11y agent as its dock's `clientScript` | | `src/client/main.ts` | Boots the client host, renders the dock rail + iframe stage | | `src/client/app-under-test.ts` | The intentionally-broken, multi-route app the agent scans | diff --git a/examples/a11y-messages-playground/src/a11y-messages-playground.ts b/examples/a11y-messages-playground/src/a11y-messages-playground.ts index 35474674..5d832137 100644 --- a/examples/a11y-messages-playground/src/a11y-messages-playground.ts +++ b/examples/a11y-messages-playground/src/a11y-messages-playground.ts @@ -1,11 +1,13 @@ import type { DevframeHubContext } from '@devframes/hub/node' import type { ClientScriptEntry } from '@devframes/hub/types' +import type { DevframeInstanceRegistration } from 'devframe/node' import type { DevframeDefinition, DevframeHost } from 'devframe/types' import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite' import { homedir } from 'node:os' +import process from 'node:process' import { createHubContext, mountDevframe } from '@devframes/hub/node' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' -import { startHttpAndWs } from 'devframe/node' +import { registerDevframeInstance, startHttpAndWs } from 'devframe/node' import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { join } from 'pathe' @@ -37,6 +39,7 @@ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions = const base = normalizeBase(options.base ?? '/__hub/') let viteConfig: ResolvedConfig | undefined let started: { close: () => Promise } | undefined + let registration: DevframeInstanceRegistration | undefined return { name: 'a11y-messages-playground', @@ -48,9 +51,12 @@ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions = async configureServer(server: ViteDevServer) { // Vite re-invokes `configureServer` on restart — tear the old server down - // so we don't leak the WS port. + // so we don't leak the WS port, and drop the previous registry record so + // a restart doesn't leave a ghost instance behind. await started?.close().catch(() => {}) started = undefined + registration?.unregister() + registration = undefined const cwd = viteConfig!.root const port = options.port ?? await getPort({ port: 9878, portRange: [9878, 9978] }) @@ -103,6 +109,37 @@ export function a11yMessagesPlayground(options: A11yMessagesPlaygroundOptions = // Tell the hub UI (served at `base`) where to find the WS endpoint. serveConnectionMeta(base) + // Register this playground in the global instance registry + // (`~/.devframe/instances/`) so discovery tooling — `devframe connect` + // and the inspector's Instances tab — lists it like any standalone + // devframe. See `examples/vite-devframe-hub` for the same pattern. + const register = (): void => { + const origin = host.resolveOrigin() + const url = new URL(origin) + registration = registerDevframeInstance({ + pid: process.pid, + port: Number(url.port) || (url.protocol === 'https:' ? 443 : 80), + origin, + basePath: base, + id: 'example:a11y-messages-playground', + name: 'A11y + Messages Playground', + rootDir: cwd, + mcp: null, + startedAt: Date.now(), + }) + } + if (server.httpServer?.listening) + register() + else + server.httpServer?.once('listening', register) + + const closeStarted = started.close + started.close = async () => { + registration?.unregister() + registration = undefined + await closeStarted() + } + server.httpServer?.once('close', () => { void started?.close().catch(() => {}) }) diff --git a/examples/next-devframe-hub/README.md b/examples/next-devframe-hub/README.md index aa19d6e6..bd69987c 100644 --- a/examples/next-devframe-hub/README.md +++ b/examples/next-devframe-hub/README.md @@ -20,6 +20,8 @@ Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's The A11y Inspector shows a live axe-core report of this hub's own page: the host serves the plugin's in-page agent module (`a11yAgentBundlePath`) same-origin through the catch-all route and attaches it as the a11y dock's `clientScript`; the hub client runtime — `createDevframeClientHost()` booted in `app/page.tsx` — imports it into the page, so the docked panel and the agent share the origin their BroadcastChannel rides. +The **RPC & State Inspector** carries an **Instances** tab that lists every devframe dev server running on your machine. The host registers itself in the shared registry (`~/.devframe/instances/`) on startup via `registerDevframeInstance()`, so it shows up as "this instance"; start another example (e.g. `pnpm --filter vite-devframe-hub dev`, or any `node bin.mjs` CLI example) in a second terminal and it appears there too, each linking to its own SPA. + ## What the example proves - `createHubContext()` boots a hub with no Vite-specific code path; a `DevframeHost` impl plugs Next specifics (static mounts, connection meta, storage, origin) in uniformly @@ -35,7 +37,7 @@ The plugins run node-side (child processes, the native `zigpty` PTY backend) and | File | Role | |---|---| -| `src/client/devframe/next-devframe-hub.ts` | The Next host — hub context, static-mount registry (incl. the a11y agent), side-car WS | +| `src/client/devframe/next-devframe-hub.ts` | The Next host — hub context, static-mount registry (incl. the a11y agent), side-car WS, instance-registry registration | | `src/client/app/%5F_hub/%5F_connection.json/route.ts` | Boots the singleton host and serves `/__hub/__connection.json` | | `src/client/app/%5F_[id]/[[...path]]/route.ts` | Serves each mounted SPA and its connection meta under `/__/` | | `src/client/app/page.tsx` | The browser UI that consumes the hub protocol | diff --git a/examples/vite-devframe-hub/README.md b/examples/vite-devframe-hub/README.md index 440ac802..6b8c1fc1 100644 --- a/examples/vite-devframe-hub/README.md +++ b/examples/vite-devframe-hub/README.md @@ -20,6 +20,8 @@ Selecting a tool loads its SPA in the stage. The bottom drawer mirrors the hub's The A11y Inspector shows a live axe-core report of this hub's own page. `vite.config.ts` attaches the plugin's in-page agent as the a11y dock's `clientScript` (served via `/@fs/`), and the hub client runtime — `createDevframeClientHost()` booted in `src/client/main.ts` — imports it into the host page. Panel and agent share the Vite origin their BroadcastChannel rides; hover a violation to ring the offending element in the hub UI. +The **RPC & State Inspector** carries an **Instances** tab that lists every devframe dev server running on your machine. The host registers itself in the shared registry (`~/.devframe/instances/`) on startup via `registerDevframeInstance()`, so it shows up as "this instance"; start another example (`pnpm --filter a11y-messages-playground dev`, or any `node bin.mjs` CLI example) in a second terminal and it appears there too, each linking to its own SPA. + ## What the example proves - `createHubContext()` boots a hub with no Vite-specific code path; a `DevframeHost` impl plugs framework specifics (static mounts, connection meta, storage, origin) in uniformly @@ -36,7 +38,7 @@ The dock UI is plain DOM in `src/client/`. To skin your own viewer, read the sam | File | Role | |---|---| -| `src/vite-devframe-hub.ts` | The Vite host — hub context, static + connection-meta mounts, side-car WS | +| `src/vite-devframe-hub.ts` | The Vite host — hub context, static + connection-meta mounts, side-car WS, instance-registry registration | | `vite.config.ts` | Mounts the built-in plugins via the host's `devframes` option; attaches the a11y agent as its dock's `clientScript` | | `src/client/main.ts` | The browser UI that consumes the hub protocol | | `src/client/icons.ts` | Offline Phosphor icons for the dock | diff --git a/examples/vite-devframe-hub/src/vite-devframe-hub.ts b/examples/vite-devframe-hub/src/vite-devframe-hub.ts index 530d08be..5c1e8750 100644 --- a/examples/vite-devframe-hub/src/vite-devframe-hub.ts +++ b/examples/vite-devframe-hub/src/vite-devframe-hub.ts @@ -1,12 +1,14 @@ import type { DevframeHubContext } from '@devframes/hub/node' import type { ClientScriptEntry } from '@devframes/hub/types' +import type { DevframeInstanceRegistration } from 'devframe/node' import type { DevframeDefinition, DevframeHost } from 'devframe/types' import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite' import { homedir } from 'node:os' +import process from 'node:process' import { defineHubRpcFunction } from '@devframes/hub' import { createHubContext, mountDevframe } from '@devframes/hub/node' import { DEVFRAME_CONNECTION_META_FILENAME } from 'devframe/constants' -import { startHttpAndWs } from 'devframe/node' +import { registerDevframeInstance, startHttpAndWs } from 'devframe/node' import { serveStaticNodeMiddleware } from 'devframe/utils/serve-static' import { getPort } from 'get-port-please' import { join } from 'pathe' @@ -74,6 +76,7 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { const base = normalizeBase(options.base ?? '/__hub/') let viteConfig: ResolvedConfig | undefined let started: { close: () => Promise } | undefined + let registration: DevframeInstanceRegistration | undefined return { name: 'vite-devframe-hub', @@ -85,9 +88,12 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { async configureServer(server: ViteDevServer) { // Vite re-invokes `configureServer` on each restart. Tear down the - // previous server so we don't leak the WS port. + // previous server so we don't leak the WS port, and drop the previous + // registry record so a restart doesn't leave a ghost instance behind. await started?.close().catch(() => {}) started = undefined + registration?.unregister() + registration = undefined const cwd = viteConfig!.root // Prefer 9777 but keep booting when it's taken (e.g. a lingering @@ -175,6 +181,41 @@ export function viteDevframeHub(options: ViteDevframeHubOptions = {}): Plugin { // Tell the hub UI (served at `base`) where to find the WS endpoint. serveConnectionMeta(base) + // Record this hub in the global instance registry (`~/.devframe/instances/`) + // so discovery tooling — `devframe connect` and the inspector's Instances + // tab — lists it like any standalone devframe. `createDevServer` registers + // automatically; an in-process host like this one registers explicitly, + // reusing the Vite dev server's own origin (where `__connection.json` + // is served). Registration waits for the server to be listening so the + // origin/port are known. Folded into `started.close` so every teardown + // path (restart, httpServer close, `closeBundle`) also unregisters. + const register = (): void => { + const origin = host.resolveOrigin() + const url = new URL(origin) + registration = registerDevframeInstance({ + pid: process.pid, + port: Number(url.port) || (url.protocol === 'https:' ? 443 : 80), + origin, + basePath: base, + id: 'example:vite-devframe-hub', + name: 'Vite Devframe Hub', + rootDir: cwd, + mcp: null, + startedAt: Date.now(), + }) + } + if (server.httpServer?.listening) + register() + else + server.httpServer?.once('listening', register) + + const closeStarted = started.close + started.close = async () => { + registration?.unregister() + registration = undefined + await closeStarted() + } + server.httpServer?.once('close', () => { void started?.close().catch(() => {}) }) diff --git a/packages/devframe/src/node/index.ts b/packages/devframe/src/node/index.ts index dcdf97ed..a511f40c 100644 --- a/packages/devframe/src/node/index.ts +++ b/packages/devframe/src/node/index.ts @@ -13,9 +13,11 @@ export type { RpcFunctionsHost } from './host-functions' export * from './host-h3' export * from './host-services' export * from './host-views' -// Only registration is public — custom hosts (e.g. @devframes/next) record -// themselves; the read/probe/prune helpers stay internal to the connector. -export { registerDevframeInstance } from './instance-registry' +// Registration is public — custom hosts (e.g. @devframes/next) record +// themselves — and live discovery is public too, so surfaces like the +// inspect plugin's Instances tab can enumerate running instances. The +// lower-level read/probe/prune helpers stay internal to the connector. +export { listLiveDevframeInstances, registerDevframeInstance } from './instance-registry' export type { DevframeInstanceRecord, DevframeInstanceRegistration } from './instance-registry' export * from './rpc-shared-state' export * from './rpc-streaming' diff --git a/plugins/inspect/README.md b/plugins/inspect/README.md index 0942baf8..fb69c308 100644 --- a/plugins/inspect/README.md +++ b/plugins/inspect/README.md @@ -7,7 +7,9 @@ A devframe plugin that inspects *its own* connection (and, when mounted in a host, the host's): browse every registered RPC function with its metadata, invoke read-only `query`/`static` functions and inspect the results, watch -shared-state keys update live, and explore the agent-exposed surface. +shared-state keys update live, explore the agent-exposed surface, and — while +running against a live backend — list the other devframe dev servers running +alongside it. Ported in spirit from the RPC & State panels of [`vitejs/devtools`](https://github.com/vitejs/devtools); rebuilt on devframe's @@ -54,7 +56,10 @@ All functions are namespaced `devframes:plugin:inspect:*`: | `invoke` | `action` | Invokes a read-only `query`/`static` function by name and returns a result envelope. Refuses `action`/`event` functions. | | `list-state-keys` | `query` (snapshot) | The keys of every shared-state entry on the connection. | | `describe-agent` | `query` (snapshot) | The agent manifest — tools and readable resources. | +| `list-instances` | `query` (live) | Every devframe dev server currently running on the machine, discovered through the shared instance registry. Powers the read-only Instances tab. | -The three `query` functions are agent-exposed (read-only) and bake into the -static dump, so the inspector still lists functions, state keys, and the agent -surface when deployed as a static SPA. +The three snapshot `query` functions are agent-exposed (read-only) and bake into +the static dump, so the inspector still lists functions, state keys, and the +agent surface when deployed as a static SPA. `list-instances` is live rather +than baked (the set of running processes is meaningless in a static dump), so +the Instances tab appears only against a live backend. diff --git a/plugins/inspect/src/client/index.ts b/plugins/inspect/src/client/index.ts index 4dd578a1..7496d252 100644 --- a/plugins/inspect/src/client/index.ts +++ b/plugins/inspect/src/client/index.ts @@ -2,7 +2,7 @@ import type { DevframeConnectionStatus, DevframeRpcClient, DevframeRpcClientOpti import { connectDevframe } from 'devframe/client' export type { DevframeConnectionStatus, DevframeRpcClient } -export type { AgentManifest, DevframeInspectCommandInfo, InvokeResult, RpcFunctionAgentInfo, RpcFunctionInfo } from '../types' +export type { AgentManifest, DevframeInspectCommandInfo, DevframeInspectInstanceInfo, InvokeResult, RpcFunctionAgentInfo, RpcFunctionInfo } from '../types' /** * Connect to the inspector's devframe backend. A thin, typed wrapper diff --git a/plugins/inspect/src/rpc/functions/list-instances.ts b/plugins/inspect/src/rpc/functions/list-instances.ts new file mode 100644 index 00000000..d277e65c --- /dev/null +++ b/plugins/inspect/src/rpc/functions/list-instances.ts @@ -0,0 +1,41 @@ +import type { DevframeInspectInstanceInfo } from '../../types' +import process from 'node:process' +import { listLiveDevframeInstances } from 'devframe/node' +import { defineInspectRpc } from './_define' + +/** + * Enumerate every devframe dev server currently running on this machine, + * discovered through the shared instance registry (`~/.devframe/instances/`) + * with a `__connection.json` liveness probe — the same discovery that backs + * the `devframe connect` bin. Powers the inspector's read-only Instances tab. + * + * Deliberately **not** `snapshot` (the set of live processes is meaningless + * baked into a static dump — the Instances tab is hidden in `build`/`spa` + * mode) and **not** agent-exposed (the `devframe connect` bin already offers + * instance discovery to agents over MCP, so exposing it here would duplicate + * that surface). + */ +export const listInstances = defineInspectRpc({ + name: 'devframes:plugin:inspect:list-instances', + type: 'query', + jsonSerializable: true, + setup: () => ({ + handler: async (): Promise => { + const { live } = await listLiveDevframeInstances() + const currentPid = process.pid + return live.map(record => ({ + id: record.id, + name: record.name, + port: record.port, + origin: record.origin, + basePath: record.basePath, + url: `${record.origin}${record.basePath}`, + pid: record.pid, + rootDir: record.rootDir, + startedAt: record.startedAt, + hasMcp: record.mcp != null, + isCurrent: record.pid === currentPid, + })) + }, + }), +}) diff --git a/plugins/inspect/src/rpc/index.ts b/plugins/inspect/src/rpc/index.ts index 0ebe8d0e..a8ac948b 100644 --- a/plugins/inspect/src/rpc/index.ts +++ b/plugins/inspect/src/rpc/index.ts @@ -5,6 +5,7 @@ import { invoke } from './functions/invoke' import { invokeAgentTool } from './functions/invoke-agent-tool' import { listCommands } from './functions/list-commands' import { listFunctions } from './functions/list-functions' +import { listInstances } from './functions/list-instances' import { listStateKeys } from './functions/list-state-keys' import { readAgentResource } from './functions/read-agent-resource' @@ -21,6 +22,7 @@ export const serverFunctions = [ readAgentResource, listCommands, executeCommand, + listInstances, ] as const declare module 'devframe' { diff --git a/plugins/inspect/src/spa/App.vue b/plugins/inspect/src/spa/App.vue index e14ff1db..5f9c8751 100644 --- a/plugins/inspect/src/spa/App.vue +++ b/plugins/inspect/src/spa/App.vue @@ -17,11 +17,12 @@ import AgentSmart from './components/AgentSmart.vue' import CommandsSmart from './components/CommandsSmart.vue' import FunctionsSmart from './components/FunctionsSmart.vue' import HistorySmart from './components/HistorySmart.vue' +import InstancesSmart from './components/InstancesSmart.vue' import StateSmart from './components/StateSmart.vue' import { useRefresh } from './composables/refresh' -import { connect, connection } from './composables/rpc' +import { connect, connection, isStatic } from './composables/rpc' -type Tab = 'functions' | 'state' | 'agent' | 'commands' | 'history' +type Tab = 'functions' | 'state' | 'agent' | 'commands' | 'history' | 'instances' const tab = ref('functions') const { refresh, loading } = useRefresh() @@ -34,14 +35,20 @@ const conn = computed(() => connectionIndicator(connection.status)) // is connected. const connState = computed(() => connectionState(connection.status)) -const tabs: { value: Tab, label: string, icon: string }[] = [ +const allTabs: { value: Tab, label: string, icon: string }[] = [ { value: 'functions', label: 'Functions', icon: 'i-ph-function-duotone' }, { value: 'state', label: 'State', icon: 'i-ph-database-duotone' }, { value: 'agent', label: 'Agent', icon: 'i-ph-robot-duotone' }, { value: 'commands', label: 'Commands', icon: 'i-ph-terminal-window-duotone' }, { value: 'history', label: 'History', icon: 'i-ph-clock-counter-clockwise-duotone' }, + { value: 'instances', label: 'Instances', icon: 'i-ph-broadcast-duotone' }, ] +// The Instances tab lists running devframe dev servers via a live node-side +// RPC — meaningless in a static `build`/`spa` dump (no backend to query), so +// it only appears when connected to a live backend. +const tabs = computed(() => isStatic() ? allTabs.filter(t => t.value !== 'instances') : allTabs) + onMounted(connect) // The client doesn't auto-reconnect; a reload re-runs the whole handshake. @@ -106,6 +113,7 @@ function reload(): void { + diff --git a/plugins/inspect/src/spa/components/InstancesSmart.vue b/plugins/inspect/src/spa/components/InstancesSmart.vue new file mode 100644 index 00000000..36dd6182 --- /dev/null +++ b/plugins/inspect/src/spa/components/InstancesSmart.vue @@ -0,0 +1,23 @@ + + + diff --git a/plugins/inspect/src/spa/components/InstancesView.stories.ts b/plugins/inspect/src/spa/components/InstancesView.stories.ts new file mode 100644 index 00000000..3451a0b7 --- /dev/null +++ b/plugins/inspect/src/spa/components/InstancesView.stories.ts @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from '@storybook/vue3-vite' +import InstancesView from './InstancesView.vue' + +const meta = { + title: 'Inspector/InstancesView', + component: InstancesView, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const now = Date.now() + +export const Default: Story = { + args: { + instances: [ + { + id: 'devframes_plugin_inspect', + name: 'Devframe Inspector', + port: 9012, + origin: 'http://127.0.0.1:9012', + basePath: '/', + url: 'http://127.0.0.1:9012/', + pid: 4821, + rootDir: '/home/dev/projects/acme/web', + startedAt: now - 42_000, + hasMcp: true, + isCurrent: true, + }, + { + id: 'devframes_plugin_git', + name: 'Git', + port: 9010, + origin: 'http://127.0.0.1:9010', + basePath: '/__git/', + url: 'http://127.0.0.1:9010/__git/', + pid: 4790, + rootDir: '/home/dev/projects/acme/web', + startedAt: now - 3_930_000, + hasMcp: false, + isCurrent: false, + }, + { + id: 'devframes_plugin_terminals', + port: 9011, + origin: 'http://127.0.0.1:9011', + basePath: '/__terminals/', + url: 'http://127.0.0.1:9011/__terminals/', + pid: 4802, + rootDir: '/home/dev/projects/other/api', + startedAt: now - 91_000_000, + hasMcp: true, + isCurrent: false, + }, + ], + }, +} + +export const Loading: Story = { + args: { + instances: null, + }, +} + +export const Empty: Story = { + args: { + instances: [], + }, +} diff --git a/plugins/inspect/src/spa/components/InstancesView.vue b/plugins/inspect/src/spa/components/InstancesView.vue new file mode 100644 index 00000000..819f4cf9 --- /dev/null +++ b/plugins/inspect/src/spa/components/InstancesView.vue @@ -0,0 +1,103 @@ + + + diff --git a/plugins/inspect/src/spa/style.css b/plugins/inspect/src/spa/style.css index a1effaab..b5609a9d 100644 --- a/plugins/inspect/src/spa/style.css +++ b/plugins/inspect/src/spa/style.css @@ -645,3 +645,95 @@ textarea.args:focus { padding: 14px; font-style: italic; } + +/* ---- instances view ---- */ +.inst-url { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + margin-top: 8px; + font-family: var(--df-mono); + font-size: 12px; + color: var(--df-accent); + text-decoration: none; +} + +.inst-url:hover { + text-decoration: underline; +} + +.inst-url-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inst-meta { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px 12px; + margin: 12px 0 0; +} + +.inst-meta > div { + min-width: 0; +} + +.inst-meta-wide { + grid-column: 1 / -1; +} + +.inst-meta dt { + font-size: 10.5px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--df-fg-faint); + font-weight: 600; +} + +.inst-meta dd { + margin: 2px 0 0; + font-family: var(--df-mono); + font-size: 12px; + color: var(--df-fg-dim); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.inst-empty { + max-width: 560px; + margin: 0 auto; + padding: 40px 20px; + text-align: center; + color: var(--df-fg-dim); +} + +.inst-empty p { + margin: 8px 0 0; + font-size: 12.5px; + line-height: 1.55; +} + +.inst-empty-icon { + font-size: 32px; + color: var(--df-fg-faint); +} + +.inst-empty-title { + margin-top: 10px !important; + font-size: 14px; + font-weight: 600; + color: var(--df-fg); +} + +.inst-empty code, +.toolbar code { + font-family: var(--df-mono); + font-size: 0.92em; + padding: 1px 5px; + border-radius: 4px; + background: var(--df-bg-active); + color: var(--df-fg-dim); +} diff --git a/plugins/inspect/src/types.ts b/plugins/inspect/src/types.ts index 8eefb7e1..6be8d368 100644 --- a/plugins/inspect/src/types.ts +++ b/plugins/inspect/src/types.ts @@ -73,6 +73,38 @@ export interface DevframeInspectCommandInfo { children?: DevframeInspectCommandInfo[] } +/** + * Serializable projection of a single running devframe instance discovered + * in the machine-wide instance registry (`~/.devframe/instances/`), returned + * by `devframes:plugin:inspect:list-instances`. A live, node-only view — the + * inspector's Instances tab renders these as a read-only directory of the + * other devframes running alongside this one. + */ +export interface DevframeInspectInstanceInfo { + /** Definition id of the running instance. */ + id: string + /** Definition display name, when the instance declares one. */ + name?: string + /** Listening port. */ + port: number + /** Dialable HTTP origin, e.g. `http://127.0.0.1:9876`. */ + origin: string + /** Base path the devframe is mounted at (trailing slash). */ + basePath: string + /** Full SPA URL (`origin` + `basePath`) — the link the tab opens. */ + url: string + /** Process id of the instance's dev server. */ + pid: number + /** Working directory the instance was started from. */ + rootDir: string + /** Epoch-ms timestamp of registration (used to compute uptime). */ + startedAt: number + /** Whether the instance exposes an MCP endpoint. */ + hasMcp: boolean + /** Whether this is the inspector's own instance (matched by pid). */ + isCurrent: boolean +} + /** * Result envelope for `devframes:plugin:inspect:invoke`. Errors are * normalized to a serializable shape rather than thrown so the inspector diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-inspect/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-inspect/client.snapshot.d.ts index 71f1a77e..bd340ba0 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-inspect/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-inspect/client.snapshot.d.ts @@ -9,6 +9,7 @@ export declare function connectInspect(_?: DevframeRpcClientOptions): Promise(_: D export declare function createStorage(_: CreateStorageOptions): SharedState; export declare function formatHostForUrl(_: string): string; export declare function isObject(_: unknown): value is Record; +export declare function listLiveDevframeInstances(_?: { + instancesDir?: string; + timeoutMs?: number; +}): Promise<{ + live: DevframeInstanceRecord[]; + pruned: DevframeInstanceRecord[]; +}>; export declare function normalizeHttpServerUrl(_: string, _: number | string): string; export declare function registerDevframeInstance(_: DevframeInstanceRecord, _?: { instancesDir?: string; diff --git a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js index f323fdf0..d95147c9 100644 --- a/tests/__snapshots__/tsnapi/devframe/node.snapshot.js +++ b/tests/__snapshots__/tsnapi/devframe/node.snapshot.js @@ -16,6 +16,7 @@ export { DevframeServicesHostImpl } export { DevframeViewHost } export { formatHostForUrl } export { isObject } +export { listLiveDevframeInstances } export { normalizeHttpServerUrl } export { registerDevframeInstance } export { startHttpAndWs }