mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
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:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user