mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
+8
-1
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user