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
+132
View File
@@ -0,0 +1,132 @@
import { truncateUtf8Prefix } from "../utils/utf8-truncate.js";
import type { NodeHostClient } from "./client.js";
import type { NodeInvokeRequestPayload } from "./invoke-types.js";
const PROGRESS_CHUNK_BYTES = 16 * 1024;
const MIN_HEARTBEAT_INTERVAL_MS = 250;
const MAX_HEARTBEAT_INTERVAL_MS = 5_000;
type Pausable = { pause(): void; resume(): void };
export function resolveNodeInvokeHeartbeatInterval(idleTimeoutMs: number): number {
return Math.max(
MIN_HEARTBEAT_INTERVAL_MS,
Math.min(MAX_HEARTBEAT_INTERVAL_MS, Math.floor(idleTimeoutMs / 2)),
);
}
export function createNodeInvokeProgressWriter(params: {
client: NodeHostClient;
frame: NodeInvokeRequestPayload;
idleTimeoutMs: number;
onError: (error: Error) => void;
}) {
let seq = 0;
let queue = Promise.resolve();
let progressError: Error | undefined;
let heartbeatQueued = false;
let heartbeatDirty = false;
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
let recurringHeartbeats = false;
let stopped = false;
let lastProgressAt = 0;
const heartbeatIntervalMs = resolveNodeInvokeHeartbeatInterval(params.idleTimeoutMs);
const recordError = (error: unknown) => {
progressError = error instanceof Error ? error : new Error(String(error));
params.onError(progressError);
};
const enqueue = (task: () => Promise<void>, pausable?: Pausable): Promise<void> => {
pausable?.pause();
queue = queue
.then(task)
.catch(recordError)
.finally(() => pausable?.resume());
return queue;
};
const sendText = async (text: string) => {
let remaining = text;
while (remaining) {
const chunk = truncateUtf8Prefix(remaining, PROGRESS_CHUNK_BYTES);
if (!chunk) {
break;
}
await params.client.request("node.invoke.progress", {
invokeId: params.frame.id,
nodeId: params.frame.nodeId,
seq,
chunk,
});
seq += 1;
remaining = remaining.slice(chunk.length);
}
};
const queueHeartbeat = () => {
if (stopped) {
return;
}
if (heartbeatQueued) {
heartbeatDirty = true;
return;
}
heartbeatQueued = true;
const delayMs = Math.max(0, heartbeatIntervalMs - (Date.now() - lastProgressAt));
heartbeatTimer = setTimeout(() => {
heartbeatTimer = undefined;
void enqueue(async () => {
await params.client.request("node.invoke.progress", {
invokeId: params.frame.id,
nodeId: params.frame.nodeId,
seq,
chunk: "",
});
seq += 1;
lastProgressAt = Date.now();
}).finally(() => {
heartbeatQueued = false;
if ((heartbeatDirty || recurringHeartbeats) && !stopped) {
heartbeatDirty = false;
queueHeartbeat();
}
});
}, delayMs);
};
return {
write(text: string, pausable?: Pausable): Promise<void> {
if (!text || stopped) {
return queue;
}
lastProgressAt = Date.now();
return enqueue(() => sendText(text), pausable);
},
queueHeartbeat,
startHeartbeats(): void {
recurringHeartbeats = true;
queueHeartbeat();
},
stopHeartbeats(): void {
recurringHeartbeats = false;
heartbeatDirty = false;
clearTimeout(heartbeatTimer);
heartbeatTimer = undefined;
heartbeatQueued = false;
},
async flush(): Promise<void> {
await queue.catch(() => {});
},
stop(): void {
stopped = true;
recurringHeartbeats = false;
heartbeatDirty = false;
clearTimeout(heartbeatTimer);
heartbeatTimer = undefined;
},
get error(): Error | undefined {
return progressError;
},
};
}