feat: resume OpenCode and Pi sessions in terminal (#107200)

* feat: resume OpenCode and Pi sessions in terminal

* refactor: keep Pi session commands internal
This commit is contained in:
Peter Steinberger
2026-07-13 23:54:40 -07:00
committed by GitHub
parent 75cd0efb04
commit e0b52a9618
7 changed files with 692 additions and 21 deletions
+8 -1
View File
@@ -382,7 +382,10 @@ catalogs on the Gateway and paired nodes. A node advertises
`opencode.sessions.list.v1` / `opencode.sessions.read.v1` when the `opencode`
CLI is installed, and `acpx.pi.sessions.list.v1` / `acpx.pi.sessions.read.v1`
when Pi's session directory exists. Approve the node pairing upgrade when new
commands first appear.
commands first appear. When the matching CLI is also available, the node adds
`opencode.terminal.resume.v1` or `acpx.pi.terminal.resume.v1`; the existing row
menu and viewer header can then reopen the selected session in its owning
terminal with `opencode --session <id>` or `pi --session <id>`.
OpenCode reads through its official CLI JSON/export surface. Pi reads its
documented JSONL session store, including project and global `settings.json`
@@ -390,6 +393,10 @@ session directories plus `PI_CODING_AGENT_DIR` and
`PI_CODING_AGENT_SESSION_DIR` overrides. Both catalogs are enabled by default;
turn them off in the Web UI under **Config > Plugins**.
Terminal resume uses the stored session working directory and the same
allowlisted duplex PTY relay as Codex and Claude. It does not expose arbitrary
node command execution.
## Invoking commands
Low-level (raw RPC):
@@ -1,4 +1,9 @@
import process from "node:process";
import {
decodeNodePtyResumeParams,
resolveExecutableFromPathEnv,
runNodePtyCommand,
} from "openclaw/plugin-sdk/node-host";
import type {
OpenClawPluginApi,
OpenClawPluginNodeHostCommand,
@@ -9,6 +14,7 @@ import type {
SessionCatalogHost,
SessionCatalogProvider,
SessionCatalogSession,
SessionCatalogTerminalPlan,
SessionCatalogTranscriptItem,
SessionsCatalogReadResult,
} from "openclaw/plugin-sdk/session-catalog";
@@ -23,6 +29,7 @@ import { piSessionStoreAvailable } from "./pi-session-paths.js";
const PI_SESSIONS_LIST_COMMAND = "acpx.pi.sessions.list.v1";
const PI_SESSION_READ_COMMAND = "acpx.pi.sessions.read.v1";
const PI_TERMINAL_RESUME_COMMAND = "acpx.pi.terminal.resume.v1";
const CAPABILITY = "pi-sessions";
const LOCAL_HOST_ID = "gateway";
@@ -41,6 +48,13 @@ const TRANSCRIPT_ITEM_TYPES = new Set([
"other",
]);
function validatePiThreadId(value: unknown): string {
if (typeof value !== "string" || !SESSION_ID_PATTERN.test(value)) {
throw new Error("INVALID_REQUEST: threadId is invalid");
}
return value;
}
function isOptionalString(value: unknown): boolean {
return value === undefined || typeof value === "string";
}
@@ -116,14 +130,14 @@ function isPiSessionCatalogEnabled(pluginConfig: unknown): boolean {
}
function createPiSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] {
const available = ({ config, env }: { config: unknown; env: NodeJS.ProcessEnv }) =>
const storeAvailable = ({ config, env }: { config: unknown; env: NodeJS.ProcessEnv }) =>
fullConfigCatalogEnabled(config) && piSessionStoreAvailable(env);
return [
{
command: PI_SESSIONS_LIST_COMMAND,
cap: CAPABILITY,
dangerous: false,
isAvailable: available,
isAvailable: storeAvailable,
handle: async (paramsJSON) =>
JSON.stringify(await listLocalPiSessionPage(parseNodeParams(paramsJSON))),
},
@@ -131,19 +145,52 @@ function createPiSessionNodeHostCommands(): OpenClawPluginNodeHostCommand[] {
command: PI_SESSION_READ_COMMAND,
cap: CAPABILITY,
dangerous: false,
isAvailable: available,
isAvailable: storeAvailable,
handle: async (paramsJSON) =>
JSON.stringify(await readLocalPiTranscriptPage(parseNodeParams(paramsJSON))),
},
{
command: PI_TERMINAL_RESUME_COMMAND,
cap: CAPABILITY,
dangerous: false,
duplex: true,
isAvailable: ({ config, env }) =>
storeAvailable({ config, env }) &&
Boolean(resolveExecutableFromPathEnv("pi", env.PATH ?? "")),
handle: async (paramsJSON, io) => {
if (!io) {
throw new Error("Pi terminal command requires duplex transport");
}
const params = decodeNodePtyResumeParams(paramsJSON, validatePiThreadId);
const record = await requireLocalPiSession(params.threadId);
const file = resolveExecutableFromPathEnv("pi", process.env.PATH ?? "");
if (!file) {
throw new Error("Pi CLI is unavailable");
}
return JSON.stringify(
await runNodePtyCommand(
{
file,
args: ["--session", params.threadId],
cwd: record.cwd,
cols: params.cols,
rows: params.rows,
},
io,
),
);
},
},
];
}
function createPiSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] {
return [
{
commands: [PI_SESSIONS_LIST_COMMAND, PI_SESSION_READ_COMMAND],
commands: [PI_SESSIONS_LIST_COMMAND, PI_SESSION_READ_COMMAND, PI_TERMINAL_RESUME_COMMAND],
defaultPlatforms: ["macos", "linux", "windows"],
handle: (context) => context.invokeNode(),
handle: (context) =>
context.command === PI_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(),
},
];
}
@@ -160,6 +207,13 @@ function unwrapNodePayload(value: unknown): unknown {
type CatalogNode = Awaited<ReturnType<PluginRuntime["nodes"]["list"]>>["nodes"][number];
function setTerminalCapability(page: PiSessionPage, canOpenTerminal: boolean): PiSessionPage {
for (const session of page.sessions) {
session.canOpenTerminal = canOpenTerminal;
}
return page;
}
async function listPiNodeHost(
runtime: PluginRuntime,
query: Parameters<SessionCatalogProvider["list"]>[0],
@@ -194,7 +248,13 @@ async function listPiNodeHost(
timeoutMs: NODE_TIMEOUT_MS,
scopes: ["operator.write"],
});
return { ...common, ...parseNodeSessionPage(unwrapNodePayload(raw)) };
const page = parseNodeSessionPage(unwrapNodePayload(raw));
const commands = node.invocableCommands ?? node.commands;
const canOpenTerminal = commands?.includes(PI_TERMINAL_RESUME_COMMAND) === true;
return {
...common,
...setTerminalCapability(page, canOpenTerminal),
};
} catch {
return {
...common,
@@ -263,7 +323,12 @@ async function listPiHosts(
limit: query.limitPerHost,
...(searchTerm ? { searchTerm } : {}),
cursor: query.cursors?.[LOCAL_HOST_ID],
})),
}).then((page) =>
setTerminalCapability(
page,
resolveExecutableFromPathEnv("pi", process.env.PATH ?? "") !== undefined,
),
)),
});
} catch {
hosts.push({
@@ -294,6 +359,85 @@ async function listPiHosts(
return [...hosts, ...nodeHosts];
}
async function requireLocalPiSession(threadId: string): Promise<SessionCatalogSession> {
const page = await listLocalPiSessionPage({ searchTerm: threadId, limit: MAX_PAGE_LIMIT });
const record = page.sessions.find((session) => session.threadId === threadId);
if (!record) {
throw new Error("Pi session is unavailable");
}
return record;
}
async function resolveNodePiSession(params: {
runtime: PluginRuntime;
nodeId: string;
threadId: string;
}): Promise<SessionCatalogSession> {
const raw = await params.runtime.nodes.invoke({
nodeId: params.nodeId,
command: PI_SESSIONS_LIST_COMMAND,
params: { searchTerm: params.threadId, limit: MAX_PAGE_LIMIT },
timeoutMs: NODE_TIMEOUT_MS,
scopes: ["operator.write"],
});
const page = parseNodeSessionPage(unwrapNodePayload(raw));
const record = page.sessions.find((session) => session.threadId === params.threadId);
if (!record) {
throw new Error("Pi session is unavailable");
}
return record;
}
async function openPiTerminal(params: {
runtime: PluginRuntime;
hostId: string;
threadId: string;
}): Promise<SessionCatalogTerminalPlan> {
const title = `pi --session ${params.threadId.slice(0, 12)}`;
if (params.hostId === LOCAL_HOST_ID) {
const record = await requireLocalPiSession(params.threadId);
const executable = resolveExecutableFromPathEnv("pi", process.env.PATH ?? "");
if (!executable) {
throw new Error("Pi CLI is unavailable");
}
return {
kind: "local",
argv: [executable, "--session", params.threadId],
...(record.cwd ? { cwd: record.cwd } : {}),
title,
};
}
if (!params.hostId.startsWith("node:")) {
throw new Error("hostId is invalid");
}
const nodeId = params.hostId.slice("node:".length);
const node = (await params.runtime.nodes.list()).nodes.find((candidate) => {
const commands = candidate.invocableCommands ?? candidate.commands;
return (
candidate.nodeId === nodeId &&
candidate.connected === true &&
commands?.includes(PI_SESSIONS_LIST_COMMAND) === true &&
commands.includes(PI_TERMINAL_RESUME_COMMAND)
);
});
if (!node) {
throw new Error("paired-node Pi terminal is unavailable");
}
const record = await resolveNodePiSession({
runtime: params.runtime,
nodeId,
threadId: params.threadId,
});
return {
kind: "node",
nodeId,
command: PI_TERMINAL_RESUME_COMMAND,
paramsJSON: JSON.stringify({ threadId: params.threadId }),
...(record.cwd ? { cwd: record.cwd } : {}),
title,
};
}
async function readPiTranscript(
runtime: PluginRuntime,
request: Parameters<SessionCatalogProvider["read"]>[0],
@@ -345,6 +489,7 @@ export function registerPiSessionCatalog(api: OpenClawPluginApi): void {
label: "Pi",
list: async (query) => await listPiHosts(api.runtime, query),
read: async (request) => await readPiTranscript(api.runtime, request),
openTerminal: async (request) => await openPiTerminal({ runtime: api.runtime, ...request }),
});
for (const command of createPiSessionNodeHostCommands()) {
api.registerNodeHostCommand(command);
+156 -2
View File
@@ -3,18 +3,30 @@ import os from "node:os";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { afterEach, describe, expect, it, vi } from "vitest";
const nodeHostMocks = vi.hoisted(() => ({
runNodePtyCommand: vi.fn(async () => ({ exitCode: 0 })),
}));
vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/node-host")>();
return { ...actual, runNodePtyCommand: nodeHostMocks.runNodePtyCommand };
});
import { registerPiSessionCatalog } from "./pi-session-catalog-plugin.js";
import { listLocalPiSessionPage, readLocalPiTranscriptPage } from "./pi-session-catalog.js";
import { piSessionStore } from "./pi-session-paths.js";
const PI_SESSIONS_LIST_COMMAND = "acpx.pi.sessions.list.v1";
const PI_SESSION_READ_COMMAND = "acpx.pi.sessions.read.v1";
const PI_TERMINAL_RESUME_COMMAND = "acpx.pi.terminal.resume.v1";
const temporaryDirectories: string[] = [];
const originalSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR;
const originalAgentDir = process.env.PI_CODING_AGENT_DIR;
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const originalPath = process.env.PATH;
async function createPiStore(assistantText = "hi"): Promise<string> {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-catalog-"));
@@ -80,6 +92,16 @@ async function createPiStore(assistantText = "hi"): Promise<string> {
return directory;
}
async function installFakePi(): Promise<string> {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-pi-cli-"));
temporaryDirectories.push(directory);
const executable = path.join(directory, "pi");
await fs.writeFile(executable, "#!/bin/sh\nexit 0\n");
await fs.chmod(executable, 0o755);
process.env.PATH = `${directory}${path.delimiter}${originalPath ?? ""}`;
return directory;
}
function registerPiNodeHostCommands(): Parameters<
OpenClawPluginApi["registerNodeHostCommand"]
>[0][] {
@@ -96,6 +118,8 @@ function registerPiNodeHostCommands(): Parameters<
}
afterEach(async () => {
nodeHostMocks.runNodePtyCommand.mockClear();
process.env.PATH = originalPath;
if (originalSessionDir === undefined) {
delete process.env.PI_CODING_AGENT_SESSION_DIR;
} else {
@@ -552,16 +576,18 @@ describe("Pi session catalog", () => {
it("auto-detects the store and honors the node-local Web UI switch", async () => {
const directory = await createPiStore();
const binDirectory = await installFakePi();
const commands = registerPiNodeHostCommands();
expect(commands.map((command) => command.command)).toEqual([
PI_SESSIONS_LIST_COMMAND,
PI_SESSION_READ_COMMAND,
PI_TERMINAL_RESUME_COMMAND,
]);
expect(
commands.every((command) =>
command.isAvailable?.({
config: {},
env: { PI_CODING_AGENT_SESSION_DIR: directory },
env: { PI_CODING_AGENT_SESSION_DIR: directory, PATH: binDirectory },
} as never),
),
).toBe(true);
@@ -571,7 +597,7 @@ describe("Pi session catalog", () => {
config: {
plugins: { entries: { acpx: { config: { piSessionCatalog: { enabled: false } } } } },
},
env: { PI_CODING_AGENT_SESSION_DIR: directory },
env: { PI_CODING_AGENT_SESSION_DIR: directory, PATH: binDirectory },
} as never),
),
).toBe(false);
@@ -593,6 +619,134 @@ describe("Pi session catalog", () => {
).toBe(false);
});
it.runIf(process.platform !== "win32")(
"opens validated local Pi sessions with the upstream terminal resume contract",
async () => {
await createPiStore();
await installFakePi();
let provider: Parameters<OpenClawPluginApi["registerSessionCatalog"]>[0] | undefined;
const commands: Parameters<OpenClawPluginApi["registerNodeHostCommand"]>[0][] = [];
registerPiSessionCatalog({
pluginConfig: {},
runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } },
registerSessionCatalog: (value: NonNullable<typeof provider>) => {
provider = value;
},
registerNodeHostCommand: (
command: Parameters<OpenClawPluginApi["registerNodeHostCommand"]>[0],
) => commands.push(command),
registerNodeInvokePolicy: vi.fn(),
} as unknown as OpenClawPluginApi);
await expect(provider!.list({ hostIds: ["gateway"] })).resolves.toEqual([
expect.objectContaining({
sessions: [expect.objectContaining({ threadId: "pi-session", canOpenTerminal: true })],
}),
]);
await expect(
provider!.openTerminal!({ hostId: "gateway", threadId: "pi-session" }),
).resolves.toEqual({
kind: "local",
argv: [expect.stringMatching(/pi$/u), "--session", "pi-session"],
cwd: "/workspace",
title: "pi --session pi-session…",
});
await expect(
provider!.openTerminal!({ hostId: "gateway", threadId: "missing" }),
).rejects.toThrow("Pi session is unavailable");
const terminal = commands.find((command) => command.command === PI_TERMINAL_RESUME_COMMAND)!;
const io = {
signal: new AbortController().signal,
onInput: vi.fn(),
emitChunk: vi.fn(),
};
await expect(
terminal.handle?.(
JSON.stringify({ threadId: "pi-session", cols: 100, rows: 30 }),
io as never,
),
).resolves.toBe(JSON.stringify({ exitCode: 0 }));
expect(nodeHostMocks.runNodePtyCommand).toHaveBeenCalledWith(
{
file: expect.stringMatching(/pi$/u),
args: ["--session", "pi-session"],
cwd: "/workspace",
cols: 100,
rows: 30,
},
io,
);
await expect(
terminal.handle?.(JSON.stringify({ threadId: "--help", cols: 100, rows: 30 }), io as never),
).rejects.toThrow("threadId is invalid");
},
);
it("opens paired-node Pi sessions only through the advertised terminal command", async () => {
let provider: Parameters<OpenClawPluginApi["registerSessionCatalog"]>[0] | undefined;
const page = {
payloadJSON: JSON.stringify({
sessions: [
{
threadId: "pi-remote",
cwd: "/remote/workspace",
status: "stored",
archived: false,
canContinue: false,
canArchive: false,
},
],
}),
};
const invoke = vi.fn().mockResolvedValue(page);
registerPiSessionCatalog({
pluginConfig: {},
runtime: {
nodes: {
list: vi.fn().mockResolvedValue({
nodes: [
{
nodeId: "node-1",
connected: true,
commands: [PI_SESSIONS_LIST_COMMAND, PI_TERMINAL_RESUME_COMMAND],
},
],
}),
invoke,
},
},
registerSessionCatalog: (value: NonNullable<typeof provider>) => {
provider = value;
},
registerNodeHostCommand: vi.fn(),
registerNodeInvokePolicy: vi.fn(),
} as unknown as OpenClawPluginApi);
await expect(provider!.list({ hostIds: ["node:node-1"] })).resolves.toEqual([
expect.objectContaining({
sessions: [expect.objectContaining({ threadId: "pi-remote", canOpenTerminal: true })],
}),
]);
await expect(
provider!.openTerminal!({ hostId: "node:node-1", threadId: "pi-remote" }),
).resolves.toEqual({
kind: "node",
nodeId: "node-1",
command: PI_TERMINAL_RESUME_COMMAND,
paramsJSON: JSON.stringify({ threadId: "pi-remote" }),
cwd: "/remote/workspace",
title: "pi --session pi-remote…",
});
expect(invoke).toHaveBeenLastCalledWith({
nodeId: "node-1",
command: PI_SESSIONS_LIST_COMMAND,
params: { searchTerm: "pi-remote", limit: 100 },
timeoutMs: 20_000,
scopes: ["operator.write"],
});
});
it("does not register the catalog when explicitly disabled", () => {
const registerSessionCatalog = vi.fn();
const api = {
+52 -11
View File
@@ -15,6 +15,20 @@ import type {
SessionsCatalogReadResult,
} from "openclaw/plugin-sdk/session-catalog";
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
OPENCODE_LOCAL_SESSION_HOST_ID as LOCAL_HOST_ID,
OPENCODE_NODE_INVOKE_TIMEOUT_MS as NODE_TIMEOUT_MS,
OPENCODE_SESSIONS_CAPABILITY as CAPABILITY,
OPENCODE_SESSIONS_LIST_COMMAND,
OPENCODE_SESSION_CATALOG_MAX_PAGE_LIMIT as MAX_PAGE_LIMIT,
OPENCODE_SESSION_ID_PATTERN as SESSION_ID_PATTERN,
OPENCODE_SESSION_READ_COMMAND,
OPENCODE_TERMINAL_RESUME_COMMAND,
} from "./session-catalog-shared.js";
import {
createOpenCodeTerminalNodeHostCommand,
openOpenCodeCatalogTerminal,
} from "./session-catalog-terminal.js";
import {
listLocalOpenCodeSessionPage,
optionalOpenCodeString,
@@ -22,17 +36,15 @@ import {
type OpenCodeSessionPage,
} from "./session-catalog.js";
export const OPENCODE_SESSIONS_LIST_COMMAND = "opencode.sessions.list.v1";
export const OPENCODE_SESSION_READ_COMMAND = "opencode.sessions.read.v1";
export {
OPENCODE_SESSIONS_LIST_COMMAND,
OPENCODE_SESSION_READ_COMMAND,
OPENCODE_TERMINAL_RESUME_COMMAND,
} from "./session-catalog-shared.js";
const CAPABILITY = "opencode-sessions";
const LOCAL_HOST_ID = "gateway";
const MAX_PAGE_LIMIT = 100;
const MAX_HOSTS = 100;
const MAX_CURSOR_LENGTH = 128;
const MAX_SEARCH_LENGTH = 500;
const NODE_TIMEOUT_MS = 35_000;
const SESSION_ID_PATTERN = /^(?!-)[A-Za-z0-9._:-]{1,256}$/u;
const TRANSCRIPT_ITEM_TYPES = new Set([
"userMessage",
"agentMessage",
@@ -163,15 +175,21 @@ export function createOpenCodeSessionNodeHostCommands(): OpenClawPluginNodeHostC
handle: async (paramsJSON) =>
JSON.stringify(await readLocalOpenCodeTranscriptPage(parseNodeParams(paramsJSON))),
},
createOpenCodeTerminalNodeHostCommand(available),
];
}
export function createOpenCodeSessionNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] {
return [
{
commands: [OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_SESSION_READ_COMMAND],
commands: [
OPENCODE_SESSIONS_LIST_COMMAND,
OPENCODE_SESSION_READ_COMMAND,
OPENCODE_TERMINAL_RESUME_COMMAND,
],
defaultPlatforms: ["macos", "linux", "windows"],
handle: (context) => context.invokeNode(),
handle: (context) =>
context.command === OPENCODE_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(),
},
];
}
@@ -188,6 +206,16 @@ function unwrapNodePayload(value: unknown): unknown {
type CatalogNode = Awaited<ReturnType<PluginRuntime["nodes"]["list"]>>["nodes"][number];
function setTerminalCapability(
page: OpenCodeSessionPage,
canOpenTerminal: boolean,
): OpenCodeSessionPage {
for (const session of page.sessions) {
session.canOpenTerminal = canOpenTerminal;
}
return page;
}
async function listOpenCodeNodeHost(
runtime: PluginRuntime,
query: Parameters<SessionCatalogProvider["list"]>[0],
@@ -222,7 +250,13 @@ async function listOpenCodeNodeHost(
timeoutMs: NODE_TIMEOUT_MS,
scopes: ["operator.write"],
});
return { ...common, ...parseNodeSessionPage(unwrapNodePayload(raw)) };
const page = parseNodeSessionPage(unwrapNodePayload(raw));
const commands = node.invocableCommands ?? node.commands;
const canOpenTerminal = commands?.includes(OPENCODE_TERMINAL_RESUME_COMMAND) === true;
return {
...common,
...setTerminalCapability(page, canOpenTerminal),
};
} catch {
return {
...common,
@@ -294,7 +328,7 @@ async function listOpenCodeHosts(
limit: query.limitPerHost,
...(searchTerm ? { searchTerm } : {}),
cursor: query.cursors?.[LOCAL_HOST_ID],
})),
}).then((page) => setTerminalCapability(page, true))),
});
} catch {
hosts.push({
@@ -378,6 +412,13 @@ export function registerOpenCodeSessionCatalog(api: OpenClawPluginApi): void {
label: "OpenCode",
list: async (query) => await listOpenCodeHosts(api.runtime, query),
read: async (request) => await readOpenCodeTranscript(api.runtime, request),
openTerminal: async (request) =>
await openOpenCodeCatalogTerminal({
runtime: api.runtime,
...request,
parseNodeSessionPage,
unwrapNodePayload,
}),
});
for (const command of createOpenCodeSessionNodeHostCommands()) {
api.registerNodeHostCommand(command);
@@ -0,0 +1,9 @@
export const OPENCODE_SESSIONS_LIST_COMMAND = "opencode.sessions.list.v1";
export const OPENCODE_SESSION_READ_COMMAND = "opencode.sessions.read.v1";
export const OPENCODE_TERMINAL_RESUME_COMMAND = "opencode.terminal.resume.v1";
export const OPENCODE_SESSIONS_CAPABILITY = "opencode-sessions";
export const OPENCODE_LOCAL_SESSION_HOST_ID = "gateway";
export const OPENCODE_SESSION_CATALOG_MAX_PAGE_LIMIT = 100;
export const OPENCODE_NODE_INVOKE_TIMEOUT_MS = 35_000;
export const OPENCODE_SESSION_ID_PATTERN = /^(?!-)[A-Za-z0-9._:-]{1,256}$/u;
@@ -0,0 +1,157 @@
// OpenCode catalog terminal ownership: validated resume commands and terminal plans.
import {
decodeNodePtyResumeParams,
resolveExecutableFromPathEnv,
runNodePtyCommand,
} from "openclaw/plugin-sdk/node-host";
import type { OpenClawPluginNodeHostCommand } from "openclaw/plugin-sdk/plugin-entry";
import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime";
import type {
SessionCatalogSession,
SessionCatalogTerminalPlan,
} from "openclaw/plugin-sdk/session-catalog";
import {
OPENCODE_LOCAL_SESSION_HOST_ID,
OPENCODE_NODE_INVOKE_TIMEOUT_MS,
OPENCODE_SESSIONS_CAPABILITY,
OPENCODE_SESSIONS_LIST_COMMAND,
OPENCODE_SESSION_CATALOG_MAX_PAGE_LIMIT,
OPENCODE_SESSION_ID_PATTERN,
OPENCODE_TERMINAL_RESUME_COMMAND,
} from "./session-catalog-shared.js";
import { listLocalOpenCodeSessionPage, type OpenCodeSessionPage } from "./session-catalog.js";
type OpenCodeTerminalDependencies = {
parseNodeSessionPage: (value: unknown) => OpenCodeSessionPage;
unwrapNodePayload: (value: unknown) => unknown;
};
function validateOpenCodeThreadId(value: unknown): string {
if (typeof value !== "string" || !OPENCODE_SESSION_ID_PATTERN.test(value)) {
throw new Error("INVALID_REQUEST: threadId is invalid");
}
return value;
}
async function requireLocalOpenCodeSession(threadId: string): Promise<SessionCatalogSession> {
const page = await listLocalOpenCodeSessionPage({
searchTerm: threadId,
limit: OPENCODE_SESSION_CATALOG_MAX_PAGE_LIMIT,
});
const record = page.sessions.find((session) => session.threadId === threadId);
if (!record) {
throw new Error("OpenCode session is unavailable");
}
return record;
}
export function createOpenCodeTerminalNodeHostCommand(
isAvailable: NonNullable<OpenClawPluginNodeHostCommand["isAvailable"]>,
): OpenClawPluginNodeHostCommand {
return {
command: OPENCODE_TERMINAL_RESUME_COMMAND,
cap: OPENCODE_SESSIONS_CAPABILITY,
dangerous: false,
duplex: true,
isAvailable,
handle: async (paramsJSON, io) => {
if (!io) {
throw new Error("OpenCode terminal command requires duplex transport");
}
const params = decodeNodePtyResumeParams(paramsJSON, validateOpenCodeThreadId);
const record = await requireLocalOpenCodeSession(params.threadId);
const file = resolveExecutableFromPathEnv("opencode", process.env.PATH ?? "");
if (!file) {
throw new Error("OpenCode CLI is unavailable");
}
return JSON.stringify(
await runNodePtyCommand(
{
file,
args: ["--session", params.threadId],
cwd: record.cwd,
cols: params.cols,
rows: params.rows,
},
io,
),
);
},
};
}
async function resolveNodeOpenCodeSession(
params: {
runtime: PluginRuntime;
nodeId: string;
threadId: string;
} & OpenCodeTerminalDependencies,
): Promise<SessionCatalogSession> {
const raw = await params.runtime.nodes.invoke({
nodeId: params.nodeId,
command: OPENCODE_SESSIONS_LIST_COMMAND,
params: { searchTerm: params.threadId, limit: OPENCODE_SESSION_CATALOG_MAX_PAGE_LIMIT },
timeoutMs: OPENCODE_NODE_INVOKE_TIMEOUT_MS,
scopes: ["operator.write"],
});
const page = params.parseNodeSessionPage(params.unwrapNodePayload(raw));
const record = page.sessions.find((session) => session.threadId === params.threadId);
if (!record) {
throw new Error("OpenCode session is unavailable");
}
return record;
}
export async function openOpenCodeCatalogTerminal(
params: {
runtime: PluginRuntime;
hostId: string;
threadId: string;
} & OpenCodeTerminalDependencies,
): Promise<SessionCatalogTerminalPlan> {
const title = `opencode --session ${params.threadId.slice(0, 12)}`;
if (params.hostId === OPENCODE_LOCAL_SESSION_HOST_ID) {
const record = await requireLocalOpenCodeSession(params.threadId);
const executable = resolveExecutableFromPathEnv("opencode", process.env.PATH ?? "");
if (!executable) {
throw new Error("OpenCode CLI is unavailable");
}
return {
kind: "local",
argv: [executable, "--session", params.threadId],
...(record.cwd ? { cwd: record.cwd } : {}),
title,
};
}
if (!params.hostId.startsWith("node:")) {
throw new Error("hostId is invalid");
}
const nodeId = params.hostId.slice("node:".length);
const node = (await params.runtime.nodes.list()).nodes.find((candidate) => {
const commands = candidate.invocableCommands ?? candidate.commands;
return (
candidate.nodeId === nodeId &&
candidate.connected === true &&
commands?.includes(OPENCODE_SESSIONS_LIST_COMMAND) === true &&
commands.includes(OPENCODE_TERMINAL_RESUME_COMMAND)
);
});
if (!node) {
throw new Error("paired-node OpenCode terminal is unavailable");
}
const record = await resolveNodeOpenCodeSession({
runtime: params.runtime,
nodeId,
threadId: params.threadId,
parseNodeSessionPage: params.parseNodeSessionPage,
unwrapNodePayload: params.unwrapNodePayload,
});
return {
kind: "node",
nodeId,
command: OPENCODE_TERMINAL_RESUME_COMMAND,
paramsJSON: JSON.stringify({ threadId: params.threadId }),
...(record.cwd ? { cwd: record.cwd } : {}),
title,
};
}
+158
View File
@@ -3,11 +3,23 @@ import os from "node:os";
import path from "node:path";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
import { afterEach, describe, expect, it, vi } from "vitest";
const nodeHostMocks = vi.hoisted(() => ({
runNodePtyCommand: vi.fn(async () => ({ exitCode: 0 })),
}));
vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/node-host")>();
return { ...actual, runNodePtyCommand: nodeHostMocks.runNodePtyCommand };
});
import {
createOpenCodeSessionNodeInvokePolicies,
createOpenCodeSessionNodeHostCommands,
isOpenCodeSessionCatalogEnabled,
OPENCODE_SESSIONS_LIST_COMMAND,
OPENCODE_SESSION_READ_COMMAND,
OPENCODE_TERMINAL_RESUME_COMMAND,
registerOpenCodeSessionCatalog,
} from "./session-catalog-plugin.js";
import {
@@ -85,6 +97,7 @@ if (args[0] === "--pure" && args[1] === "db" && args.includes("--format") && arg
}
afterEach(async () => {
nodeHostMocks.runNodePtyCommand.mockClear();
process.env.PATH = originalPath;
if (originalUnrelatedEnv === undefined) {
delete process.env.CATALOG_UNRELATED_ENV;
@@ -193,6 +206,7 @@ describe("OpenCode session catalog", () => {
expect(commands.map((command) => command.command)).toEqual([
OPENCODE_SESSIONS_LIST_COMMAND,
OPENCODE_SESSION_READ_COMMAND,
OPENCODE_TERMINAL_RESUME_COMMAND,
]);
expect(
commands.every((command) =>
@@ -222,6 +236,150 @@ describe("OpenCode session catalog", () => {
},
);
it.runIf(process.platform !== "win32")(
"opens validated local sessions with the upstream terminal resume contract",
async () => {
await installFakeOpenCode();
let provider: Parameters<OpenClawPluginApi["registerSessionCatalog"]>[0] | undefined;
registerOpenCodeSessionCatalog({
pluginConfig: {},
runtime: { nodes: { list: vi.fn().mockResolvedValue({ nodes: [] }) } },
registerSessionCatalog: (value: NonNullable<typeof provider>) => {
provider = value;
},
registerNodeHostCommand: vi.fn(),
registerNodeInvokePolicy: vi.fn(),
} as unknown as OpenClawPluginApi);
await expect(provider!.list({ hostIds: ["gateway"] })).resolves.toEqual([
expect.objectContaining({
sessions: [expect.objectContaining({ threadId: "ses_test", canOpenTerminal: true })],
}),
]);
await expect(
provider!.openTerminal!({ hostId: "gateway", threadId: "ses_test" }),
).resolves.toEqual({
kind: "local",
argv: [expect.stringMatching(/opencode$/u), "--session", "ses_test"],
cwd: "/workspace",
title: "opencode --session ses_test…",
});
await expect(
provider!.openTerminal!({ hostId: "gateway", threadId: "missing" }),
).rejects.toThrow("OpenCode session is unavailable");
},
);
it.runIf(process.platform !== "win32")(
"runs only catalog-validated OpenCode sessions through the node PTY",
async () => {
await installFakeOpenCode();
const terminal = createOpenCodeSessionNodeHostCommands().find(
(command) => command.command === OPENCODE_TERMINAL_RESUME_COMMAND,
);
const io = {
signal: new AbortController().signal,
onInput: vi.fn(),
emitChunk: vi.fn(),
};
await expect(
terminal!.handle?.(
JSON.stringify({ threadId: "ses_test", cols: 100, rows: 30 }),
io as never,
),
).resolves.toBe(JSON.stringify({ exitCode: 0 }));
expect(nodeHostMocks.runNodePtyCommand).toHaveBeenCalledWith(
{
file: expect.stringMatching(/opencode$/u),
args: ["--session", "ses_test"],
cwd: "/workspace",
cols: 100,
rows: 30,
},
io,
);
await expect(
terminal!.handle?.(
JSON.stringify({ threadId: "--help", cols: 100, rows: 30 }),
io as never,
),
).rejects.toThrow("threadId is invalid");
const invokeNode = vi.fn(() => ({ ok: false as const, error: "unexpected" }));
const policy = createOpenCodeSessionNodeInvokePolicies()[0]!;
expect(
policy.handle({ command: OPENCODE_TERMINAL_RESUME_COMMAND, invokeNode } as never),
).toEqual({ ok: true });
expect(
policy.handle({ command: OPENCODE_SESSIONS_LIST_COMMAND, invokeNode } as never),
).toEqual({ ok: false, error: "unexpected" });
},
);
it("marks paired-node sessions terminal-capable only when the resume command is advertised", async () => {
let provider: Parameters<OpenClawPluginApi["registerSessionCatalog"]>[0] | undefined;
const page = {
payloadJSON: JSON.stringify({
sessions: [
{
threadId: "ses_remote",
cwd: "/remote/workspace",
status: "stored",
archived: false,
canContinue: false,
canArchive: false,
},
],
}),
};
const invoke = vi.fn().mockResolvedValue(page);
registerOpenCodeSessionCatalog({
pluginConfig: {},
runtime: {
nodes: {
list: vi.fn().mockResolvedValue({
nodes: [
{
nodeId: "node-1",
connected: true,
commands: [OPENCODE_SESSIONS_LIST_COMMAND, OPENCODE_TERMINAL_RESUME_COMMAND],
},
],
}),
invoke,
},
},
registerSessionCatalog: (value: NonNullable<typeof provider>) => {
provider = value;
},
registerNodeHostCommand: vi.fn(),
registerNodeInvokePolicy: vi.fn(),
} as unknown as OpenClawPluginApi);
await expect(provider!.list({ hostIds: ["node:node-1"] })).resolves.toEqual([
expect.objectContaining({
sessions: [expect.objectContaining({ threadId: "ses_remote", canOpenTerminal: true })],
}),
]);
await expect(
provider!.openTerminal!({ hostId: "node:node-1", threadId: "ses_remote" }),
).resolves.toEqual({
kind: "node",
nodeId: "node-1",
command: OPENCODE_TERMINAL_RESUME_COMMAND,
paramsJSON: JSON.stringify({ threadId: "ses_remote" }),
cwd: "/remote/workspace",
title: "opencode --session ses_remote…",
});
expect(invoke).toHaveBeenLastCalledWith({
nodeId: "node-1",
command: OPENCODE_SESSIONS_LIST_COMMAND,
params: { searchTerm: "ses_remote", limit: 100 },
timeoutMs: 35_000,
scopes: ["operator.write"],
});
});
it("does not register the catalog when explicitly disabled", () => {
const registerSessionCatalog = vi.fn();
const api = {