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
8 changes: 8 additions & 0 deletions design/design.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,14 @@ export function card(extra?: string): string {
return cx('flex flex-col rounded-xl border border-base bg-base shadow-sm', extra)
}

export function modalBackdrop(extra?: string): string {
return cx('fixed inset-0 z-modal-backdrop grid place-items-center p-4 bg-black/40 backdrop-blur-sm', extra)
}

export function modalCard(extra?: string): string {
return cx('z-modal-content w-full max-w-sm flex flex-col gap-3 p-4 rounded-xl border border-base bg-base shadow-lg', extra)
}

export function panel(extra?: string): string {
return cx('rounded-lg border border-base bg-base', extra)
}
Expand Down
30 changes: 30 additions & 0 deletions docs/errors/DF8204.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
outline: deep
---

# DF8204: Terminal Session Cannot Be Controlled

## Message

> Terminal session "`{id}`" cannot be controlled (no lifecycle handle)
## Cause

`hub:terminals:terminate` or `hub:terminals:restart` was called for a session
that carries no lifecycle handle. Sessions added with a bare
`ctx.terminals.register()` are just a descriptor plus an output stream — they
expose no `terminate()` / `restart()`. Only sessions spawned via
`ctx.terminals.startChildProcess()` or `ctx.terminals.startPtySession()` own
their process and can be terminated or restarted.

## Fix

- Spawn the session via `ctx.terminals.startChildProcess(executeOptions, terminal)`
or `ctx.terminals.startPtySession(executeOptions, terminal)` so it owns a
process the hub can control.
- For a bare `register()` session, drive its lifecycle from whatever owns the
underlying process; `hub:terminals:remove` still drops it from the registry.

## Source

- [`packages/hub/src/node/rpc-builtins.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/rpc-builtins.ts) — the `hub:terminals:terminate` / `hub:terminals:restart` handlers throw this when the resolved session has no `terminate` handle.
30 changes: 30 additions & 0 deletions docs/errors/DF8205.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
outline: deep
---

# DF8205: Terminal Session Is Not Restartable

## Message

> Terminal session "`{id}`" is not restartable

## Cause

`hub:terminals:restart` was called for a session registered with
`restartable: false`. That flag marks a session whose lifecycle is owned
elsewhere — for example a one-shot build, or a server (like code-server) that
should be restarted through its own controls rather than by re-spawning the raw
process. A hub-aware terminal UI hides its restart affordance for these
sessions, and the RPC rejects a direct call.

## Fix

- Restart the session through its owner's controls (the plugin or dock that
started it), not the generic terminal restart.
- If in-place restarts are safe, spawn it with `restartable: true` (the default)
in the `terminal` descriptor passed to `startChildProcess()` /
`startPtySession()`.

## Source

- [`packages/hub/src/node/rpc-builtins.ts`](https://github.com/devframes/devframe/blob/main/packages/hub/src/node/rpc-builtins.ts) — the `hub:terminals:restart` handler throws this when the resolved session has `restartable: false`.
2 changes: 1 addition & 1 deletion examples/minimal-next-devframe-hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"description": "Protocol-witness example — a tiny Next.js Devframe Hub built on @devframes/hub that exercises every hub subsystem end-to-end.",
"homepage": "https://github.com/devframes/devframe/tree/main/examples/minimal-next-devframe-hub",
"scripts": {
"dev": "next dev src/client -H 0.0.0.0",
"dev": "pnpm -C ../.. run build && next dev src/client",
"build": "next build src/client",
"test": "vitest run --config vitest.config.ts"
},
Expand Down
2 changes: 1 addition & 1 deletion examples/minimal-vite-devframe-hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"description": "Protocol-witness example — a tiny Vite Devframe Hub built on @devframes/hub that exercises every hub subsystem end-to-end.",
"homepage": "https://github.com/devframes/devframe/tree/main/examples/minimal-vite-devframe-hub",
"scripts": {
"dev": "vite",
"dev": "pnpm -C ../.. run build && vite",
"build": "vite build",
"typecheck": "tsc --noEmit"
},
Expand Down
83 changes: 82 additions & 1 deletion packages/hub/src/node/__tests__/rpc-builtins.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import type { DevframeHubContext } from '../context'
import { describe, expect, it, vi } from 'vitest'
import { hubDocksActivate, hubTerminalsResize, hubTerminalsWrite } from '../rpc-builtins'
import {
hubDocksActivate,
hubTerminalsRemove,
hubTerminalsResize,
hubTerminalsRestart,
hubTerminalsTerminate,
hubTerminalsWrite,
} from '../rpc-builtins'

function contextWithSessions(sessions: Map<string, any>): DevframeHubContext {
return { terminals: { sessions } } as unknown as DevframeHubContext
Expand Down Expand Up @@ -38,6 +45,80 @@ describe('hub terminal write/resize RPC', () => {
})
})

describe('hub terminal terminate/restart/remove RPC', () => {
it('terminates a controllable (child-process or pty) session', async () => {
const terminate = vi.fn()
const ctx = contextWithSessions(new Map([
['log', { id: 'log', type: 'child-process', terminate }],
]))
const fn = await hubTerminalsTerminate.setup!(ctx)
await fn.handler!('log')
expect(terminate).toHaveBeenCalledOnce()
})

it('restarts a restartable session', async () => {
const restart = vi.fn()
const ctx = contextWithSessions(new Map([
['log', { id: 'log', type: 'child-process', terminate: vi.fn(), restart }],
]))
const fn = await hubTerminalsRestart.setup!(ctx)
await fn.handler!('log')
expect(restart).toHaveBeenCalledOnce()
})

it('rejects restarting a session marked restartable: false', async () => {
const restart = vi.fn()
const ctx = contextWithSessions(new Map([
['svc', { id: 'svc', type: 'child-process', terminate: vi.fn(), restart, restartable: false }],
]))
const fn = await hubTerminalsRestart.setup!(ctx)
await expect(fn.handler!('svc')).rejects.toThrow(/not restartable/i)
expect(restart).not.toHaveBeenCalled()
})

it('rejects controlling a session with no lifecycle handle', async () => {
const ctx = contextWithSessions(new Map([
['bare', { id: 'bare' }],
]))
const fn = await hubTerminalsTerminate.setup!(ctx)
await expect(fn.handler!('bare')).rejects.toThrow(/cannot be controlled/i)
})

it('kills then drops a session on remove', async () => {
const terminate = vi.fn()
const remove = vi.fn()
const session = { id: 'log', type: 'child-process', terminate }
const ctx = {
terminals: { sessions: new Map([['log', session]]), remove },
} as unknown as DevframeHubContext
const fn = await hubTerminalsRemove.setup!(ctx)
await fn.handler!('log')
expect(terminate).toHaveBeenCalledOnce()
expect(remove).toHaveBeenCalledWith(session)
})

it('removes a bare registered session without a terminate handle', async () => {
const remove = vi.fn()
const session = { id: 'mirror' }
const ctx = {
terminals: { sessions: new Map([['mirror', session]]), remove },
} as unknown as DevframeHubContext
const fn = await hubTerminalsRemove.setup!(ctx)
await fn.handler!('mirror')
expect(remove).toHaveBeenCalledWith(session)
})

it('rejects removing an unknown session', async () => {
const remove = vi.fn()
const ctx = {
terminals: { sessions: new Map(), remove },
} as unknown as DevframeHubContext
const fn = await hubTerminalsRemove.setup!(ctx)
await expect(fn.handler!('nope')).rejects.toThrow(/not registered/i)
expect(remove).not.toHaveBeenCalled()
})
})

describe('hub docks activate RPC', () => {
it('forwards dockId and params to docks.activate', async () => {
const activate = vi.fn()
Expand Down
8 changes: 8 additions & 0 deletions packages/hub/src/node/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ export const diagnostics = defineDiagnostics({
DF8203: {
why: (p: { command: string, reason: string }) => `Failed to spawn PTY session for "${p.command}": ${p.reason}`,
},
DF8204: {
why: (p: { id: string }) => `Terminal session "${p.id}" cannot be controlled (no lifecycle handle)`,
fix: 'Spawn it via ctx.terminals.startChildProcess() or startPtySession() — sessions added with a bare register() expose no terminate/restart handle.',
},
DF8205: {
why: (p: { id: string }) => `Terminal session "${p.id}" is not restartable`,
fix: 'It was registered with `restartable: false`; restart it through its owner\'s controls, or spawn it with `restartable: true` (the default) to allow in-place restarts.',
},
DF8400: {
why: (p: { id: string }) => `Command "${p.id}" is already registered`,
},
Expand Down
83 changes: 82 additions & 1 deletion packages/hub/src/node/rpc-builtins.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { RpcFunctionDefinitionAny } from 'devframe/rpc'
import type { DevframeMessageEntry, DevframeMessageEntryInput } from '../types/messages'
import type { DevframePtyTerminalSession } from '../types/terminals'
import type {
DevframeChildProcessTerminalSession,
DevframePtyTerminalSession,
} from '../types/terminals'
import { defineHubRpcFunction } from '../define'
import { diagnostics } from './diagnostics'

Expand All @@ -20,6 +23,26 @@ function resolveInteractiveSession(
return session
}

/** A session exposing lifecycle handles — spawned via startChildProcess/startPtySession. */
type ControllableTerminalSession = DevframeChildProcessTerminalSession | DevframePtyTerminalSession

/**
* Resolve a session that can be terminated/restarted (spawned via
* `startChildProcess` or `startPtySession`), or throw. Sessions added with a
* bare `register()` carry no lifecycle handle and are rejected.
*/
function resolveControllableSession(
sessions: Map<string, { id: string }>,
id: string,
): ControllableTerminalSession {
const session = sessions.get(id) as ControllableTerminalSession | undefined
if (!session)
throw diagnostics.DF8201({ id })
if (typeof session.terminate !== 'function')
throw diagnostics.DF8204({ id })
return session
}

/**
* `hub:commands:execute` — Invoke a registered server command by id. The
* arguments after `id` are forwarded to the command's `handler(...)`.
Expand Down Expand Up @@ -119,6 +142,61 @@ export const hubTerminalsResize = defineHubRpcFunction({
}),
})

/**
* `hub:terminals:terminate` — Kill a session's process while keeping the
* session registered (its output/scrollback stays). Works for both read-only
* child-process and interactive PTY sessions, letting a hub-aware terminal UI
* force-kill a session owned by another plugin.
*/
export const hubTerminalsTerminate = defineHubRpcFunction({
name: 'hub:terminals:terminate',
type: 'action',
setup: context => ({
async handler(id: string): Promise<void> {
await resolveControllableSession(context.terminals.sessions, id).terminate()
},
}),
})

/**
* `hub:terminals:restart` — Re-run a session's command in place. Rejected for
* sessions registered with `restartable: false`, whose lifecycle is owned
* elsewhere.
*/
export const hubTerminalsRestart = defineHubRpcFunction({
name: 'hub:terminals:restart',
type: 'action',
setup: context => ({
async handler(id: string): Promise<void> {
const session = resolveControllableSession(context.terminals.sessions, id)
if (session.restartable === false)
throw diagnostics.DF8205({ id })
await session.restart()
},
}),
})

/**
* `hub:terminals:remove` — Kill a session's process (when it still owns one)
* and drop it from the registry, disposing its output stream. Lets a hub-aware
* terminal UI discard a stopped aggregated session.
*/
export const hubTerminalsRemove = defineHubRpcFunction({
name: 'hub:terminals:remove',
type: 'action',
setup: context => ({
async handler(id: string): Promise<void> {
const session = context.terminals.sessions.get(id)
if (!session)
throw diagnostics.DF8201({ id })
const controllable = session as Partial<ControllableTerminalSession>
if (typeof controllable.terminate === 'function')
await controllable.terminate()
context.terminals.remove(session)
},
}),
})

/**
* `hub:docks:activate` — Ask the active viewer to switch its focused dock to
* `dockId`, optionally carrying `params` for the target dock to interpret
Expand Down Expand Up @@ -156,4 +234,7 @@ export const builtinHubRpcDeclarations: readonly RpcFunctionDefinitionAny[] = [
hubMessagesClear,
hubTerminalsWrite,
hubTerminalsResize,
hubTerminalsTerminate,
hubTerminalsRestart,
hubTerminalsRemove,
]
11 changes: 11 additions & 0 deletions packages/hub/src/types/terminals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export interface DevframeTerminalsHost {

register: (session: DevframeTerminalSession) => DevframeTerminalSession
update: (session: DevframeTerminalSession) => void
/** Drop a session from the registry, disposing its bound output stream. */
remove: (session: DevframeTerminalSession) => void

/**
* Spawn a read-only child process (pipe-backed, output only). Use this for
Expand Down Expand Up @@ -50,6 +52,15 @@ export interface DevframeTerminalSessionBase {
* decide whether to enable stdin and wire resize.
*/
interactive?: boolean
/**
* Whether the session may be restarted in place (re-running its command).
* Defaults to `true`. Set `false` for sessions whose lifecycle is owned
* elsewhere — e.g. a one-shot build, or a server (like code-server) that
* should be restarted through its own controls rather than by re-spawning
* the raw process. A hub-aware terminal UI hides its restart affordance for
* these, and `hub:terminals:restart` rejects them.
*/
restartable?: boolean
}

export interface DevframeTerminalSession extends DevframeTerminalSessionBase {
Expand Down
6 changes: 5 additions & 1 deletion plugins/code-server/src/node/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ interface HubTerminalsBridge {
remove?: (session: { id: string }) => void
startChildProcess: (
executeOptions: { command: string, args: string[], cwd?: string, env?: Record<string, string> },
terminal: { id: string, title: string, description?: string, icon?: string },
terminal: { id: string, title: string, description?: string, icon?: string, restartable?: boolean },
) => Promise<HubChildProcessSession>
}

Expand Down Expand Up @@ -557,6 +557,10 @@ export class CodeServerSupervisor {
title: TERMINAL_SESSION_TITLE,
description: folder,
icon: TERMINAL_SESSION_ICON,
// Restarting the editor means re-running the supervisor's start
// flow (fresh port + secret), not re-spawning this raw process, so
// hide the terminal panel's generic restart for this session.
restartable: false,
},
)
const child = session.getChildProcess()
Expand Down
Loading
Loading