mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-25 21:41:10 +00:00
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.
144 lines
4.5 KiB
TypeScript
144 lines
4.5 KiB
TypeScript
import { statSync } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import {
|
|
decodeNodePtyResumeParams,
|
|
resolveExecutableFromPathEnv,
|
|
runNodePtyCommand,
|
|
validateClaudeSessionId,
|
|
} from "openclaw/plugin-sdk/node-host";
|
|
import type {
|
|
OpenClawPluginNodeHostCommand,
|
|
OpenClawPluginNodeInvokePolicy,
|
|
} from "openclaw/plugin-sdk/plugin-entry";
|
|
import {
|
|
CLAUDE_CLI_NODE_RUN_COMMAND,
|
|
CLAUDE_SESSION_READ_COMMAND,
|
|
CLAUDE_SESSIONS_LIST_COMMAND,
|
|
CLAUDE_TERMINAL_RESUME_COMMAND,
|
|
isResumableClaudeSource,
|
|
} from "./session-catalog-shared.js";
|
|
import type { ClaudeSessionCatalogSession } from "./session-catalog-types.js";
|
|
import { listLocalClaudeSessionPage, readLocalClaudeTranscriptPage } from "./session-catalog.js";
|
|
|
|
const CLAUDE_SESSIONS_CAPABILITY = "claude-sessions";
|
|
const CLAUDE_NODE_LOOKUP_PAGE_LIMIT = 100;
|
|
|
|
// Nodes advertise the catalog commands only when this machine has a Claude
|
|
// Code session store; without it the gateway skips the node entirely.
|
|
function claudeProjectsAvailable(env: NodeJS.ProcessEnv): boolean {
|
|
const homeDir = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
|
|
try {
|
|
return statSync(path.join(homeDir, ".claude", "projects")).isDirectory();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function parseNodeParams(paramsJSON?: string | null): unknown {
|
|
if (!paramsJSON) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
return JSON.parse(paramsJSON) as unknown;
|
|
} catch (error) {
|
|
throw new Error("Claude session parameters must be valid JSON", { cause: error });
|
|
}
|
|
}
|
|
|
|
async function requireLocalResumableClaudeSession(
|
|
threadId: string,
|
|
): Promise<ClaudeSessionCatalogSession> {
|
|
let cursor: string | undefined;
|
|
const seenCursors = new Set<string>();
|
|
while (true) {
|
|
const page = await listLocalClaudeSessionPage({
|
|
limit: CLAUDE_NODE_LOOKUP_PAGE_LIMIT,
|
|
...(cursor ? { cursor } : {}),
|
|
});
|
|
const record = page.sessions.find((candidate) => candidate.threadId === threadId);
|
|
if (record) {
|
|
if (isResumableClaudeSource(record.source)) {
|
|
return record;
|
|
}
|
|
break;
|
|
}
|
|
const nextCursor = page.nextCursor?.trim();
|
|
if (!nextCursor || seenCursors.has(nextCursor)) {
|
|
break;
|
|
}
|
|
seenCursors.add(nextCursor);
|
|
cursor = nextCursor;
|
|
}
|
|
throw new Error("Claude session cannot be resumed in a terminal");
|
|
}
|
|
|
|
export function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] {
|
|
return [
|
|
{
|
|
command: CLAUDE_SESSIONS_LIST_COMMAND,
|
|
cap: CLAUDE_SESSIONS_CAPABILITY,
|
|
dangerous: false,
|
|
isAvailable: ({ env }) => claudeProjectsAvailable(env),
|
|
handle: async (paramsJSON) =>
|
|
JSON.stringify(await listLocalClaudeSessionPage(parseNodeParams(paramsJSON))),
|
|
},
|
|
{
|
|
command: CLAUDE_SESSION_READ_COMMAND,
|
|
cap: CLAUDE_SESSIONS_CAPABILITY,
|
|
dangerous: false,
|
|
isAvailable: ({ env }) => claudeProjectsAvailable(env),
|
|
handle: async (paramsJSON) =>
|
|
JSON.stringify(await readLocalClaudeTranscriptPage(parseNodeParams(paramsJSON))),
|
|
},
|
|
{
|
|
command: CLAUDE_TERMINAL_RESUME_COMMAND,
|
|
cap: CLAUDE_SESSIONS_CAPABILITY,
|
|
dangerous: false,
|
|
duplex: true,
|
|
isAvailable: ({ env }) =>
|
|
claudeProjectsAvailable(env) &&
|
|
Boolean(resolveExecutableFromPathEnv("claude", env.PATH ?? "")),
|
|
handle: async (paramsJSON, io) => {
|
|
if (!io) {
|
|
throw new Error("Claude terminal command requires duplex transport");
|
|
}
|
|
const params = decodeNodePtyResumeParams(paramsJSON, validateClaudeSessionId);
|
|
const record = await requireLocalResumableClaudeSession(params.threadId);
|
|
const file = resolveExecutableFromPathEnv("claude", process.env.PATH ?? "");
|
|
if (!file) {
|
|
throw new Error("Claude CLI is unavailable");
|
|
}
|
|
return JSON.stringify(
|
|
await runNodePtyCommand(
|
|
{
|
|
file,
|
|
args: ["--resume", params.threadId],
|
|
cwd: record.cwd,
|
|
cols: params.cols,
|
|
rows: params.rows,
|
|
},
|
|
io,
|
|
),
|
|
);
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
export function createClaudeSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] {
|
|
return [
|
|
{
|
|
commands: [
|
|
CLAUDE_SESSIONS_LIST_COMMAND,
|
|
CLAUDE_SESSION_READ_COMMAND,
|
|
CLAUDE_CLI_NODE_RUN_COMMAND,
|
|
CLAUDE_TERMINAL_RESUME_COMMAND,
|
|
],
|
|
defaultPlatforms: ["macos", "linux", "windows"],
|
|
handle: (context) =>
|
|
context.command === CLAUDE_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(),
|
|
},
|
|
];
|
|
}
|