From 5ea7f98e19a908aec1aa6edfb26ce1cee1832f7b Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 14 Jul 2026 09:46:36 +0000 Subject: [PATCH 1/2] feat(hub): expose getResult() on startChildProcess() terminal sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a getResult() accessor to DevframeChildProcessTerminalSession that mirrors tinyexec's Result contract — a promise-like handle with live pid/exitCode/killed getters that resolves to { stdout, stderr, exitCode } once the process exits. stdout and stderr are now captured separately (alongside the existing merged display stream) by listening on the raw child process directly, rather than through tinyexec's own async iterator/promise, which would otherwise starve each other reading the same underlying streams. This gives tinyexec/execa-based subprocess-runner APIs (e.g. Nuxt DevTools' startSubprocess().getResult()) a compatible seam to adopt ctx.terminals.startChildProcess() directly. Co-authored-by: opencode --- docs/guide/hub.md | 2 +- docs/plugins/terminals.md | 2 + .../src/node/__tests__/host-terminals.test.ts | 78 +++++++++++++++++ packages/hub/src/node/host-terminals.ts | 84 +++++++++++++++---- packages/hub/src/types/terminals.ts | 34 ++++++++ .../tsnapi/@devframes/hub/index.snapshot.d.ts | 2 + .../tsnapi/@devframes/hub/types.snapshot.d.ts | 2 + 7 files changed, 188 insertions(+), 16 deletions(-) diff --git a/docs/guide/hub.md b/docs/guide/hub.md index 5971519..8d3e2bc 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -13,7 +13,7 @@ A hub-aware node context (`DevframeHubContext`) extends `DevframeNodeContext` wi | Subsystem | Surface | Purpose | |---|---|---| | `ctx.docks` | `register / update / values` | Multi-tool dock entries (iframes, launchers, json-render, custom-render) and groups that collapse them under one button. | -| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, stream output over a well-known channel. The single source of truth for "what sessions exist" — see [Terminals](/plugins/terminals#hub-aggregation) for how the terminals plugin renders and mirrors into it. | +| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, stream output over a well-known channel. The single source of truth for "what sessions exist" — see [Terminals](/plugins/terminals#hub-aggregation) for how the terminals plugin renders and mirrors into it. A `startChildProcess()` session's `getResult()` mirrors `tinyexec`'s `Result` (promise-like, plus live `pid` / `exitCode` / `killed`), so subprocess-runner APIs built on `tinyexec`/`execa` can adopt it with little change. | | `ctx.messages` | `add / update / remove / clear` | Server-side toast/notification queue (FIFO, capped at 1000). | | `ctx.commands` | `register / execute / list` | Hierarchical command palette with keybindings and `when` clauses. | diff --git a/docs/plugins/terminals.md b/docs/plugins/terminals.md index c39db75..f91e013 100644 --- a/docs/plugins/terminals.md +++ b/docs/plugins/terminals.md @@ -56,6 +56,8 @@ Mounted into a hub, the plugin owns PTY/child-process spawning and its own strea `ctx.terminals` is the source of truth for "what sessions exist"; the plugin is the panel that renders them and the one PTY-capable provider among possibly several session sources. The plugin never imports `@devframes/hub`'s types to stay mountable without a hub — it duck-types the minimal `register` / `update` / `events` shape it needs. +A session from `ctx.terminals.startChildProcess()` carries a `getResult()` accessor shaped like `tinyexec`'s `Result` — `await`able to `{ stdout, stderr, exitCode }` (captured separately from the merged display stream), with live `pid` / `exitCode` / `killed` getters and `kill()` in the meantime. That's the seam for migrating an existing `tinyexec`/`execa`-based "run a subprocess and get its result" API onto the hub's terminals: keep the same calling code, swap the runner for `startChildProcess()`, and the session's output shows up in every hub-aware terminal panel for free. + ## Source [`plugins/terminals`](https://github.com/devframes/devframe/tree/main/plugins/terminals) diff --git a/packages/hub/src/node/__tests__/host-terminals.test.ts b/packages/hub/src/node/__tests__/host-terminals.test.ts index a6b4e17..7ccd870 100644 --- a/packages/hub/src/node/__tests__/host-terminals.test.ts +++ b/packages/hub/src/node/__tests__/host-terminals.test.ts @@ -177,6 +177,84 @@ describe('devframeTerminalHost stream lifecycle', () => { // Stream stays closed; no orphan output stream. expect(sinks.get('child')?.closed).toBe(true) }) + + it('getResult() resolves with separately captured stdout/stderr and the exit code', async () => { + const { host } = createTerminalHost() + + const session = await host.startChildProcess({ + command: process.execPath, + args: ['-e', 'process.stdout.write("out"); process.stderr.write("err"); process.exit(3)'], + }, { + id: 'child', + title: 'Child', + }) + + const output = await session.getResult() + expect(output).toEqual({ stdout: 'out', stderr: 'err', exitCode: 3 }) + // The merged display stream still carries both, for terminal rendering. + expect(session.buffer?.join('')).toContain('out') + expect(session.buffer?.join('')).toContain('err') + }) + + it('getResult() exposes live pid/exitCode/killed before and after exit', async () => { + const { host } = createTerminalHost() + + const session = await host.startChildProcess({ + command: process.execPath, + args: ['-e', 'setTimeout(() => process.exit(0), 50)'], + }, { + id: 'child', + title: 'Child', + }) + + const result = session.getResult() + expect(result.pid).toBeTypeOf('number') + expect(result.exitCode).toBeUndefined() + expect(result.killed).toBe(false) + + await result + expect(result.exitCode).toBe(0) + }) + + it('getResult() tracks the new run after restart()', async () => { + const { host } = createTerminalHost() + + // Long-running, so the stream is still open (and `restart()` allowed) + // by the time we restart it. + const session = await host.startChildProcess({ + command: process.execPath, + args: ['-e', 'setInterval(() => {}, 1000)'], + }, { + id: 'child', + title: 'Child', + }) + + const firstHandle = session.getResult() + expect(firstHandle.exitCode).toBeUndefined() + + await session.restart() + const secondHandle = session.getResult() + // A fresh result handle for the fresh run, not the stale first one. + expect(secondHandle).not.toBe(firstHandle) + + await session.terminate() + }) + + it('getResult() still settles with the real exit code after terminate()', async () => { + const { host, sinks } = createTerminalHost() + const session = await host.startChildProcess( + { command: process.execPath, args: ['-e', 'setInterval(() => {}, 1000)'] }, + { id: 'child', title: 'Child' }, + ) + const result = session.getResult() + await session.terminate() + await waitUntil(() => { + expect(sinks.get('child')?.closed).toBe(true) + }) + + const output = await result + expect(output.exitCode).toBeUndefined() + }) }) describe('devframeTerminalHost interactive PTY sessions', () => { diff --git a/packages/hub/src/node/host-terminals.ts b/packages/hub/src/node/host-terminals.ts index 417e697..ed24ff3 100644 --- a/packages/hub/src/node/host-terminals.ts +++ b/packages/hub/src/node/host-terminals.ts @@ -1,8 +1,11 @@ import type { RpcStreamingChannel } from 'devframe/types' +import type { Buffer } from 'node:buffer' import type { Result as TinyExecResult } from 'tinyexec' import type { IPty } from 'zigpty' import type { DevframeChildProcessExecuteOptions, + DevframeChildProcessOutput, + DevframeChildProcessResult, DevframeChildProcessTerminalSession, DevframePtyExecuteOptions, DevframePtyTerminalSession, @@ -169,6 +172,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { let controller: ReadableStreamDefaultController | undefined let cp: TinyExecResult | undefined + let currentResult: DevframeChildProcessResult | undefined let runId = 0 let streamClosed = false @@ -225,21 +229,70 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { }, ) - ;(async () => { - try { - for await (const chunk of cp) { - if (streamClosed || currentRun !== runId) - return - controller?.enqueue(chunk) - } - if (currentRun === runId) - closeStream() - } - catch (error) { - if (currentRun === runId) - errorStream(error) - } - })() + // Capture stdout/stderr separately (for `getResult()`) by listening on + // the raw child process directly, rather than consuming `cp`'s own + // async iterator/promise — those merge stdout+stderr line-by-line and + // would starve one another if both were read from. + const stdoutChunks: string[] = [] + const stderrChunks: string[] = [] + let settled = false + let resolveOutput!: (output: DevframeChildProcessOutput) => void + const outputPromise = new Promise((resolve) => { + resolveOutput = resolve + }) + + const settle = (exitCode: number | undefined) => { + if (settled || currentRun !== runId) + return + settled = true + resolveOutput({ + stdout: stdoutChunks.join(''), + stderr: stderrChunks.join(''), + exitCode, + }) + } + + cp.process?.stdout?.on('data', (chunk: Buffer | string) => { + if (currentRun !== runId) + return + const text = chunk.toString() + stdoutChunks.push(text) + if (!streamClosed) + controller?.enqueue(text) + }) + cp.process?.stderr?.on('data', (chunk: Buffer | string) => { + if (currentRun !== runId) + return + const text = chunk.toString() + stderrChunks.push(text) + if (!streamClosed) + controller?.enqueue(text) + }) + cp.process?.once('error', (error) => { + if (currentRun !== runId) + return + settle(cp.process?.exitCode ?? undefined) + errorStream(error) + }) + cp.process?.once('close', (code) => { + settle(code ?? undefined) + if (currentRun === runId) + closeStream() + }) + + currentResult = { + get pid() { + return cp.process?.pid + }, + get exitCode() { + return cp.process?.exitCode ?? undefined + }, + get killed() { + return cp.process?.killed === true + }, + kill: signal => cp.kill(signal), + then: (onfulfilled, onrejected) => outputPromise.then(onfulfilled, onrejected), + } return cp } @@ -265,6 +318,7 @@ export class DevframeTerminalsHost implements DevframeTerminalsHostType { type: 'child-process', executeOptions, getChildProcess: () => cp?.process, + getResult: () => currentResult!, terminate, restart, } diff --git a/packages/hub/src/types/terminals.ts b/packages/hub/src/types/terminals.ts index 1f5c6a7..2e21735 100644 --- a/packages/hub/src/types/terminals.ts +++ b/packages/hub/src/types/terminals.ts @@ -64,10 +64,44 @@ export interface DevframeChildProcessExecuteOptions { env?: Record } +/** + * The settled outcome of a {@link DevframeChildProcessTerminalSession} run — + * stdout/stderr captured separately (unlike the session's merged display + * `stream`), plus the process's exit code (`undefined` if it was killed by a + * signal before exiting). + */ +export interface DevframeChildProcessOutput { + stdout: string + stderr: string + exitCode: number | undefined +} + +/** + * A live handle on a child process's outcome — mirrors the ergonomics of + * `tinyexec`'s `Result` (a promise-like paired with synchronous accessors) so + * callers migrating from a `tinyexec`/`execa`-based subprocess API (e.g. + * Nuxt DevTools' `startSubprocess().getResult()`) can adopt + * {@link DevframeTerminalsHost.startChildProcess} with minimal changes. + * `await`ing it (or calling `.then()`) resolves once the process exits, with + * the full captured {@link DevframeChildProcessOutput}. + */ +export interface DevframeChildProcessResult extends PromiseLike { + readonly pid: number | undefined + /** `undefined` while the process is still running. */ + readonly exitCode: number | undefined + readonly killed: boolean + kill: (signal?: NodeJS.Signals | number) => boolean +} + export interface DevframeChildProcessTerminalSession extends DevframeTerminalSession { type: 'child-process' executeOptions: DevframeChildProcessExecuteOptions getChildProcess: () => ChildProcess | undefined + /** + * Get a live handle on the current run's outcome. Reflects the most recent + * `restart()` — call it again after restarting to track the new run. + */ + getResult: () => DevframeChildProcessResult terminate: () => Promise restart: () => Promise } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 6230f5d..93cac2b 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -22,6 +22,8 @@ export { ConnectionMeta } export { CreateHubContextOptions } export { DevframeCapabilities } export { DevframeChildProcessExecuteOptions } +export { DevframeChildProcessOutput } +export { DevframeChildProcessResult } export { DevframeChildProcessTerminalSession } export { DevframeClientCommand } export { DevframeCommandBase } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts index 752de5c..d832b93 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/types.snapshot.d.ts @@ -8,6 +8,8 @@ export { ConnectionMeta } export { CreateHubContextOptions } export { DevframeCapabilities } export { DevframeChildProcessExecuteOptions } +export { DevframeChildProcessOutput } +export { DevframeChildProcessResult } export { DevframeChildProcessTerminalSession } export { DevframeClientCommand } export { DevframeCommandBase } From 973d4b99fcb5be0d04346abbacd726d27a793d31 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Tue, 14 Jul 2026 09:51:30 +0000 Subject: [PATCH 2/2] revert(docs): drop hub.md getResult() note --- docs/guide/hub.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/hub.md b/docs/guide/hub.md index 8d3e2bc..5971519 100644 --- a/docs/guide/hub.md +++ b/docs/guide/hub.md @@ -13,7 +13,7 @@ A hub-aware node context (`DevframeHubContext`) extends `DevframeNodeContext` wi | Subsystem | Surface | Purpose | |---|---|---| | `ctx.docks` | `register / update / values` | Multi-tool dock entries (iframes, launchers, json-render, custom-render) and groups that collapse them under one button. | -| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, stream output over a well-known channel. The single source of truth for "what sessions exist" — see [Terminals](/plugins/terminals#hub-aggregation) for how the terminals plugin renders and mirrors into it. A `startChildProcess()` session's `getResult()` mirrors `tinyexec`'s `Result` (promise-like, plus live `pid` / `exitCode` / `killed`), so subprocess-runner APIs built on `tinyexec`/`execa` can adopt it with little change. | +| `ctx.terminals` | `register / startChildProcess` | Aggregate terminal sessions, stream output over a well-known channel. The single source of truth for "what sessions exist" — see [Terminals](/plugins/terminals#hub-aggregation) for how the terminals plugin renders and mirrors into it. | | `ctx.messages` | `add / update / remove / clear` | Server-side toast/notification queue (FIFO, capped at 1000). | | `ctx.commands` | `register / execute / list` | Hierarchical command palette with keybindings and `when` clauses. |