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

View File

@@ -184,4 +184,39 @@ describe("plugin node-host registry", () => {
await expect(invokeRegisteredNodeHostCommand("missing.command", null)).resolves.toBeNull();
expect(handle).toHaveBeenCalledWith('{"ok":true}');
});
it("gates duplex commands from embedded-worker manifests and supplies their IO context", async () => {
const handle = vi.fn(async (paramsJSON?: string | null) => paramsJSON ?? "");
const registry = createEmptyPluginRegistry();
registry.nodeHostCommands = [
{
pluginId: "terminal",
pluginName: "Terminal",
command: {
command: "terminal.resume.v1",
cap: "terminal",
duplex: true,
handle,
},
source: "test",
},
];
setActivePluginRegistry(registry);
expect(
listRegisteredNodeHostCapsAndCommands(availabilityContext, { includeDuplex: false }),
).toEqual({ caps: [], commands: [], nodePluginTools: [] });
const io = {
signal: new AbortController().signal,
emitChunk: async () => {},
onInput: () => {},
};
await expect(
invokeRegisteredNodeHostCommand("terminal.resume.v1", '{"threadId":"id"}', io),
).resolves.toBe('{"threadId":"id"}');
expect(handle).toHaveBeenCalledWith('{"threadId":"id"}', io);
await expect(invokeRegisteredNodeHostCommand("terminal.resume.v1", null)).rejects.toThrow(
"requires duplex transport",
);
});
});