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
+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 = {