Files
openclaw/extensions/anthropic/session-catalog-node-commands.ts
Peter Steinberger d8d2f83cc1 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.
2026-07-13 22:20:50 -07:00

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(),
},
];
}