feat(terminal): open Codex/Claude catalog sessions in a terminal on their owning host

Catalog session rows (sidebar context menu + click), the built-in viewer
header, and a new "Open Codex/Claude sessions in" preference can launch the
native CLI (codex resume / claude --resume) in the operator terminal on the
machine that owns the session.

- Gateway-local sessions spawn through the existing terminal launch policy
  (sandbox/enabled gates preserved) with the resume command in the session cwd.
- Paired-node sessions run through a new seq-ordered node PTY relay: a
  duplex node-host command streams PTY output via node.invoke.progress and
  receives keystrokes/resize via a new node.invoke.input event, behind the
  unchanged terminal.* client protocol (TerminalSessionManager gains a backend
  abstraction; node relay reuses the streaming-invoke controller).
- Owner boundary: each plugin owns its resume command and builds argv from a
  validated thread id; the gateway routes node opens through the node command
  allowlist and plugin invoke policy (no advertisement-only trust), and nodes
  re-verify session eligibility before spawning.
- UI setting catalogOpenTarget + canOpenTerminal capability gate every entry
  point; capability requires the owning host to actually have the CLI.

Node PATH is normalized before command-availability probes, Windows .cmd/.bat
shims spawn via ComSpec, and catalog terminal opens reattach persisted tabs
before opening the new tab.
This commit is contained in:
Peter Steinberger
2026-07-13 16:33:07 -07:00
parent 177ad6bb61
commit d8d2f83cc1
145 changed files with 4809 additions and 853 deletions
+23 -1
View File
@@ -3,7 +3,10 @@ import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/s
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginNodeHostCommandRegistration } from "../plugins/registry-types.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import type { OpenClawPluginNodeHostCommandAvailabilityContext } from "../plugins/types.js";
import type {
OpenClawPluginNodeHostCommandAvailabilityContext,
OpenClawPluginNodeHostCommandIo,
} from "../plugins/types.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
/**
@@ -33,6 +36,7 @@ export async function ensureNodeHostPluginRegistry(params: {
/** List registered node-host capabilities and command ids in deterministic order. */
export function listRegisteredNodeHostCapsAndCommands(
context: OpenClawPluginNodeHostCommandAvailabilityContext,
options: { includeDuplex?: boolean } = {},
): {
caps: string[];
commands: string[];
@@ -43,6 +47,9 @@ export function listRegisteredNodeHostCapsAndCommands(
const commands = new Set<string>();
const nodePluginTools = new Map<string, NodePluginToolDescriptor>();
for (const entry of registry?.nodeHostCommands ?? []) {
if (entry.command.duplex === true && options.includeDuplex === false) {
continue;
}
// Availability belongs to the node-local plugin. Gateway policy still keeps
// the command registered so a differently configured remote node can expose it.
if (entry.command.isAvailable?.(context) === false) {
@@ -113,6 +120,7 @@ function buildNodePluginToolDescriptor(
export async function invokeRegisteredNodeHostCommand(
command: string,
paramsJSON?: string | null,
io?: OpenClawPluginNodeHostCommandIo,
): Promise<string | null> {
const registry = getActivePluginRegistry();
const match = (registry?.nodeHostCommands ?? []).find(
@@ -121,5 +129,19 @@ export async function invokeRegisteredNodeHostCommand(
if (!match) {
return null;
}
if (match.command.duplex === true) {
if (!io) {
throw new Error(`node command requires duplex transport: ${command}`);
}
return await match.command.handle(paramsJSON, io);
}
return await match.command.handle(paramsJSON);
}
export function isRegisteredNodeHostCommandDuplex(command: string): boolean {
const registry = getActivePluginRegistry();
return (
(registry?.nodeHostCommands ?? []).find((entry) => entry.command.command === command)?.command
.duplex === true
);
}