From d8d2f83cc1969968d95d105e7430a98b56dd4bbd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 13 Jul 2026 16:33:07 -0700 Subject: [PATCH] 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. --- .../.generated/plugin-sdk-api-baseline.sha256 | 4 +- docs/nodes/index.md | 18 +- docs/plugins/codex-supervision.md | 17 +- docs/web/control-ui.md | 4 + .../session-catalog-node-commands.ts | 85 +++- .../anthropic/session-catalog-shared.ts | 12 + .../anthropic/session-catalog-terminal.ts | 136 ++++++ extensions/anthropic/session-catalog-types.ts | 46 ++ extensions/anthropic/session-catalog.test.ts | 213 ++++++++- extensions/anthropic/session-catalog.ts | 105 ++--- .../src/session-catalog-node-continue.ts | 10 +- .../codex/src/session-catalog-parsing.ts | 6 + .../codex/src/session-catalog-terminal.ts | 218 +++++++++ extensions/codex/src/session-catalog-types.ts | 19 + extensions/codex/src/session-catalog.test.ts | 199 ++++++++- extensions/codex/src/session-catalog.ts | 118 +++-- package.json | 4 + packages/gateway-protocol/src/index.ts | 13 +- .../src/schema/nodes-invoke-input.test.ts | 32 ++ packages/gateway-protocol/src/schema/nodes.ts | 9 + .../schema/protocol-schemas-node-invoke.ts | 17 + .../src/schema/protocol-schemas.ts | 10 +- .../src/schema/sessions-catalog.test.ts | 12 +- .../src/schema/sessions-catalog.ts | 2 + .../src/schema/terminal.test.ts | 33 ++ .../gateway-protocol/src/schema/terminal.ts | 8 + scripts/deadcode-exports.baseline.mjs | 7 + scripts/lib/plugin-sdk-entrypoints.json | 1 + scripts/plugin-sdk-surface-report.mjs | 6 +- .../protocol-event-coverage.allowlist.json | 6 +- src/gateway/node-registry.invoke-stream.ts | 91 +++- src/gateway/node-registry.test.ts | 77 ++++ src/gateway/node-registry.ts | 148 +++---- src/gateway/server-methods-list.ts | 1 + .../server-methods/session-catalog.test.ts | 18 + src/gateway/server-methods/session-catalog.ts | 9 +- src/gateway/server-methods/shared-types.ts | 1 + .../server-methods/terminal-open-plan.ts | 62 +++ src/gateway/server-methods/terminal.test.ts | 419 +++++++++++++++++- src/gateway/server-methods/terminal.ts | 175 ++++++-- src/gateway/server-request-context.ts | 2 + src/gateway/terminal/backend.ts | 31 ++ src/gateway/terminal/launch.test.ts | 60 ++- src/gateway/terminal/launch.ts | 37 +- src/gateway/terminal/node-relay.test.ts | 90 ++++ src/gateway/terminal/node-relay.ts | 145 ++++++ src/gateway/terminal/output-ring.ts | 33 ++ src/gateway/terminal/pty.ts | 68 --- src/gateway/terminal/session-manager.test.ts | 48 +- src/gateway/terminal/session-manager.ts | 98 ++-- src/infra/node-commands.ts | 5 + .../invoke-agent-cli-claude-handler.ts | 2 + .../invoke-agent-cli-claude-params.ts | 9 + src/node-host/invoke-agent-cli-claude.ts | 130 +----- src/node-host/invoke-payload.ts | 28 ++ src/node-host/invoke.ts | 8 +- src/node-host/node-invoke-progress.test.ts | 64 +++ src/node-host/node-invoke-progress.ts | 132 ++++++ src/node-host/plugin-node-host.test.ts | 35 ++ src/node-host/plugin-node-host.ts | 24 +- src/node-host/pty-command.test.ts | 82 ++++ src/node-host/pty-command.ts | 163 +++++++ src/node-host/runner.test.ts | 57 ++- src/node-host/runner.ts | 13 +- src/node-host/runtime.test.ts | 64 +++ src/node-host/runtime.ts | 135 +++++- src/plugin-sdk/node-host.ts | 9 + src/plugin-sdk/session-catalog.ts | 1 + src/plugins/session-catalog.ts | 15 + src/plugins/types.node-host.ts | 39 ++ src/plugins/types.ts | 35 +- .../terminal-pty.test.ts} | 40 +- src/process/terminal-pty.ts | 92 ++++ ui/src/app/app-host.ts | 23 +- ui/src/app/settings-normalizers.ts | 19 + ui/src/app/settings.node.test.ts | 25 ++ ui/src/app/settings.ts | 48 +- ui/src/components/app-sidebar-catalog-menu.ts | 77 ++++ .../app-sidebar-session-catalogs.ts | 76 +++- ui/src/components/app-sidebar.test.ts | 76 +++- ui/src/components/app-sidebar.ts | 52 +-- .../components/catalog-session-menu.test.ts | 89 ++++ ui/src/components/catalog-session-menu.ts | 116 +++++ ui/src/components/panel-toggle-contract.ts | 5 + .../terminal/terminal-connection.test.ts | 24 + .../terminal/terminal-connection.ts | 9 +- .../terminal/terminal-panel.test.ts | 132 +++++- ui/src/components/terminal/terminal-panel.ts | 73 ++- .../terminal/terminal-session-storage.ts | 27 ++ .../terminal/terminal-task-queue.test.ts | 36 ++ .../terminal/terminal-task-queue.ts | 35 ++ ui/src/i18n/.i18n/ar.meta.json | 8 +- ui/src/i18n/.i18n/de.meta.json | 8 +- ui/src/i18n/.i18n/es.meta.json | 8 +- ui/src/i18n/.i18n/fa.meta.json | 8 +- ui/src/i18n/.i18n/fr.meta.json | 8 +- ui/src/i18n/.i18n/hi.meta.json | 8 +- ui/src/i18n/.i18n/hi.tm.jsonl | 1 + ui/src/i18n/.i18n/id.meta.json | 8 +- ui/src/i18n/.i18n/it.meta.json | 8 +- ui/src/i18n/.i18n/ja-JP.meta.json | 8 +- ui/src/i18n/.i18n/ko.meta.json | 8 +- ui/src/i18n/.i18n/nl.meta.json | 8 +- ui/src/i18n/.i18n/pl.meta.json | 8 +- ui/src/i18n/.i18n/pt-BR.meta.json | 8 +- ui/src/i18n/.i18n/ru.meta.json | 8 +- ui/src/i18n/.i18n/ru.tm.jsonl | 1 + ui/src/i18n/.i18n/th.meta.json | 8 +- ui/src/i18n/.i18n/tr.meta.json | 8 +- ui/src/i18n/.i18n/uk.meta.json | 8 +- ui/src/i18n/.i18n/vi.meta.json | 8 +- ui/src/i18n/.i18n/zh-CN.meta.json | 8 +- ui/src/i18n/.i18n/zh-TW.meta.json | 8 +- ui/src/i18n/locales/ar.ts | 7 + ui/src/i18n/locales/de.ts | 7 + ui/src/i18n/locales/en.ts | 7 + ui/src/i18n/locales/es.ts | 7 + ui/src/i18n/locales/fa.ts | 7 + ui/src/i18n/locales/fr.ts | 7 + ui/src/i18n/locales/hi.ts | 7 + ui/src/i18n/locales/id.ts | 7 + ui/src/i18n/locales/it.ts | 7 + ui/src/i18n/locales/ja-JP.ts | 7 + ui/src/i18n/locales/ko.ts | 7 + ui/src/i18n/locales/nl.ts | 7 + ui/src/i18n/locales/pl.ts | 7 + ui/src/i18n/locales/pt-BR.ts | 7 + ui/src/i18n/locales/ru.ts | 7 + ui/src/i18n/locales/th.ts | 7 + ui/src/i18n/locales/tr.ts | 7 + ui/src/i18n/locales/uk.ts | 7 + ui/src/i18n/locales/vi.ts | 7 + ui/src/i18n/locales/zh-CN.ts | 7 + ui/src/i18n/locales/zh-TW.ts | 7 + ui/src/lib/sessions/catalog-terminal.ts | 13 + ui/src/lib/terminal-availability.ts | 15 + ui/src/pages/chat/catalog-message-id.ts | 8 + ui/src/pages/chat/chat-pane.test.ts | 66 +++ ui/src/pages/chat/chat-pane.ts | 7 +- .../components/catalog-terminal-button.ts | 30 ++ ui/src/pages/config/config-page.ts | 20 +- ui/src/pages/config/settings-search.ts | 5 +- ui/src/pages/config/settings-select-row.ts | 34 ++ ui/src/pages/config/view.browser.test.ts | 2 + ui/src/pages/config/view.ts | 46 +- 145 files changed, 4809 insertions(+), 853 deletions(-) create mode 100644 extensions/anthropic/session-catalog-shared.ts create mode 100644 extensions/anthropic/session-catalog-terminal.ts create mode 100644 extensions/anthropic/session-catalog-types.ts create mode 100644 extensions/codex/src/session-catalog-terminal.ts create mode 100644 packages/gateway-protocol/src/schema/nodes-invoke-input.test.ts create mode 100644 packages/gateway-protocol/src/schema/protocol-schemas-node-invoke.ts create mode 100644 packages/gateway-protocol/src/schema/terminal.test.ts create mode 100644 src/gateway/server-methods/terminal-open-plan.ts create mode 100644 src/gateway/terminal/backend.ts create mode 100644 src/gateway/terminal/node-relay.test.ts create mode 100644 src/gateway/terminal/node-relay.ts create mode 100644 src/gateway/terminal/output-ring.ts delete mode 100644 src/gateway/terminal/pty.ts create mode 100644 src/node-host/node-invoke-progress.test.ts create mode 100644 src/node-host/node-invoke-progress.ts create mode 100644 src/node-host/pty-command.test.ts create mode 100644 src/node-host/pty-command.ts create mode 100644 src/node-host/runtime.test.ts create mode 100644 src/plugin-sdk/node-host.ts create mode 100644 src/plugins/types.node-host.ts rename src/{gateway/terminal/pty.test.ts => process/terminal-pty.test.ts} (54%) create mode 100644 src/process/terminal-pty.ts create mode 100644 ui/src/app/settings-normalizers.ts create mode 100644 ui/src/components/app-sidebar-catalog-menu.ts create mode 100644 ui/src/components/catalog-session-menu.test.ts create mode 100644 ui/src/components/catalog-session-menu.ts create mode 100644 ui/src/components/terminal/terminal-session-storage.ts create mode 100644 ui/src/components/terminal/terminal-task-queue.test.ts create mode 100644 ui/src/components/terminal/terminal-task-queue.ts create mode 100644 ui/src/lib/sessions/catalog-terminal.ts create mode 100644 ui/src/lib/terminal-availability.ts create mode 100644 ui/src/pages/chat/catalog-message-id.ts create mode 100644 ui/src/pages/chat/components/catalog-terminal-button.ts create mode 100644 ui/src/pages/config/settings-select-row.ts diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 3e56bf69708e..4e47d20844ed 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -38de898c2b05a0895c4863373aa7128f073128cc6ace8bd5004203c3ee9735fd plugin-sdk-api-baseline.json -93ca4d93917be8be643293c2a11c58d2a7c138390c15f646991a223815ddec1e plugin-sdk-api-baseline.jsonl +6b509179fce1f5115037be7432b2dc21dda507351296ce5ca769e24741cece87 plugin-sdk-api-baseline.json +d317f5ef0a767f557357a2f7699e27e0f436edd18298401ee7a982805bcd1f76 plugin-sdk-api-baseline.jsonl diff --git a/docs/nodes/index.md b/docs/nodes/index.md index 99394c2ad30e..5aa46c13f228 100644 --- a/docs/nodes/index.md +++ b/docs/nodes/index.md @@ -295,17 +295,17 @@ state. The node advertises the versioned read-only `codex.appServer.threads.list.v1` and -`codex.appServer.thread.turns.list.v1` commands. Approve the node pairing +`codex.appServer.thread.turns.list.v1` commands. A native node host with the +Codex CLI available also advertises `codex.terminal.resume.v1`. Approve the node pairing upgrade when those commands first appear. The Gateway invokes them through the normal plugin node policy and isolates failures by host. Paired-node rows appear as a **Codex** group in the normal sessions sidebar. -Selecting a row opens the normal Chat pane and reads its persisted transcript +By default, selecting a row opens the normal Chat pane and reads its persisted transcript through bounded, cursor-paginated -`thread/turns/list` calls with full item projection. The node invoke transport is request/response only and cannot -carry the streaming turns, live events, or approvals required to continue a -native thread through the Codex harness. **Continue** and **Archive** are -therefore unavailable for remote rows. On the Gateway computer, stored and idle +`thread/turns/list` calls with full item projection. Use the row menu, the viewer header, or the **Open Codex/Claude sessions in** preference to start `codex resume ` in the operator terminal on the computer that owns the session. The paired-node terminal path is an allowlisted PTY relay owned by the Codex plugin, not arbitrary node command execution. + +The relay does not provide the full OpenClaw harness continuation and archive ownership contracts. **Continue** and **Archive** are therefore unavailable for remote rows. On the Gateway computer, stored and idle rows can start a distinct model-locked Chat branch. Either can be archived only after the operator confirms that no other Codex client is using it; a stored row's live activity remains unknown. Active rows cannot branch or archive. @@ -322,6 +322,12 @@ this needs no separate opt-in: a remote macOS app node advertises when the Anthropic plugin is enabled and `~/.claude/projects/` exists. Approve the node pairing upgrade when those commands first appear. +A native node host with the Claude CLI available also advertises +`anthropic.claude.terminal.resume.v1`. Eligible CLI and Desktop rows can open +`claude --resume ` in the operator terminal on their owning host. +This is a takeover of the native session; unlike OpenClaw adoption, it does not +fork the Claude session first. + The catalog combines valid Claude CLI project-index records with a bounded metadata prefix from current `sdk-cli` JSONL files. Claude Desktop's local metadata supplies Desktop titles and archive state. Desktop metadata wins when diff --git a/docs/plugins/codex-supervision.md b/docs/plugins/codex-supervision.md index c9d7ee02dfbe..d5e4ccef76c4 100644 --- a/docs/plugins/codex-supervision.md +++ b/docs/plugins/codex-supervision.md @@ -304,16 +304,19 @@ codex unarchive Paired nodes expose the versioned read-only `codex.appServer.threads.list.v1` and -`codex.appServer.thread.turns.list.v1` commands. The Gateway receives normalized +`codex.appServer.thread.turns.list.v1` commands. Native node hosts with the +Codex CLI available also expose the allowlisted `codex.terminal.resume.v1` +command. The Gateway receives normalized metadata and explicitly requested bounded transcript pages, never raw App Server -endpoints. The current node invoke -transport is request/response only, so it cannot carry the long-lived event, -approval, and streaming lifecycle required by the Codex harness. +endpoints. Opening a row in the operator terminal runs `codex resume ` +on the owning host and relays that command's PTY; it does not expose a general +shell or gateway-supplied argv. -For that reason, remote rows remain visible but do not offer **Continue** or +The terminal relay does not provide the harness continuation or archive ownership +contracts. Remote rows therefore remain visible but do not offer **Continue** or **Archive**, even when the remote thread is idle. Use Codex on that computer -until a node-side streaming runner bridge exists for continuation and a safe -runner-ownership boundary exists for archive. +through **Open in terminal**, or use a future continuation flow with a safe +runner-ownership boundary. ## Metadata and permissions diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 6eb5407f8379..a8facbbb1965 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -319,6 +319,10 @@ The terminal is an unconfined host shell and inherits the Gateway process enviro Use **Ctrl + backtick** to toggle the dock. The layout supports bottom and right docking, resizes with the browser viewport, and keeps multiple shell tabs. See [Gateway configuration](/gateway/configuration-reference#gateway) for `gateway.terminal.enabled` and the optional `gateway.terminal.shell` override. +Codex and Claude Code sessions discovered in the sessions sidebar can open in their native CLI inside the same terminal panel. In **Settings › Chat**, set **Open Codex/Claude sessions in** to **Terminal** to make a normal row click open `codex resume` or `claude --resume`; the default remains the read-only OpenClaw viewer. A row's right-click or kebab menu always offers both choices, and the viewer header includes **Open in terminal** when that session is eligible. + +Eligibility is per session and per host. Gateway-local sessions start the provider-owned resume command on the Gateway host. Paired-node sessions start an allowlisted provider command on the owning node and relay only that PTY's output, input, and resize events; this does not expose a general node shell or accept browser-supplied commands. Nodes that do not advertise the matching terminal-resume command, including embedded worker bridges without duplex streaming, keep the viewer available and show terminal opening as unavailable. + Sessions survive disconnects: a page reload, laptop sleep, or network blip detaches the session on the Gateway instead of killing it, and the same browser tab reattaches on reconnect with recent output replayed. Detached sessions are killed after `gateway.terminal.detachedSessionTimeoutSeconds` (default 300 seconds; `0` restores kill-on-disconnect). `terminal.list` shows attachable sessions, `terminal.attach` adopts one (tmux-style take-over), and `terminal.text` reads a session's recent output as plain text without attaching - an agent/tooling affordance. The terminal is also available as a full-screen, terminal-only document at `/?view=terminal`. The iOS and Android apps embed this page in their Terminal screens, reusing the stored gateway credentials; availability follows the same `gateway.terminal.enabled` and `operator.admin` gate, and the page shows a notice when the connected Gateway does not offer the terminal. diff --git a/extensions/anthropic/session-catalog-node-commands.ts b/extensions/anthropic/session-catalog-node-commands.ts index e4eff732e827..3634850261c1 100644 --- a/extensions/anthropic/session-catalog-node-commands.ts +++ b/extensions/anthropic/session-catalog-node-commands.ts @@ -1,18 +1,28 @@ 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, - listLocalClaudeSessionPage, - readLocalClaudeTranscriptPage, -} from "./session-catalog.js"; + 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. @@ -36,6 +46,33 @@ function parseNodeParams(paramsJSON?: string | null): unknown { } } +async function requireLocalResumableClaudeSession( + threadId: string, +): Promise { + let cursor: string | undefined; + const seenCursors = new Set(); + 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 [ { @@ -54,15 +91,53 @@ export function createClaudeSessionNodeHostCommands(): OpenClawPluginNodeHostCom 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], + 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.invokeNode(), + handle: (context) => + context.command === CLAUDE_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(), }, ]; } diff --git a/extensions/anthropic/session-catalog-shared.ts b/extensions/anthropic/session-catalog-shared.ts new file mode 100644 index 000000000000..3f35db014765 --- /dev/null +++ b/extensions/anthropic/session-catalog-shared.ts @@ -0,0 +1,12 @@ +// Dependency-free Claude catalog contracts shared by catalog and terminal ownership. +export const CLAUDE_SESSIONS_LIST_COMMAND = "anthropic.claude.sessions.list.v1"; +export const CLAUDE_SESSION_READ_COMMAND = "anthropic.claude.sessions.read.v1"; +export const CLAUDE_CLI_NODE_RUN_COMMAND = "agent.cli.claude.run.v1"; +export const CLAUDE_TERMINAL_RESUME_COMMAND = "anthropic.claude.terminal.resume.v1"; + +export class ClaudeCatalogParamsError extends Error {} + +// Desktop sessions share the resumable projects store with CLI sessions. +export function isResumableClaudeSource(source: string | undefined): boolean { + return source === "claude-cli" || source === "claude-desktop"; +} diff --git a/extensions/anthropic/session-catalog-terminal.ts b/extensions/anthropic/session-catalog-terminal.ts new file mode 100644 index 000000000000..c5502b1b212f --- /dev/null +++ b/extensions/anthropic/session-catalog-terminal.ts @@ -0,0 +1,136 @@ +// Claude catalog terminal ownership: validated local and paired-node resume plans. +import fs from "node:fs/promises"; +import { resolveExecutableFromPathEnv } from "openclaw/plugin-sdk/node-host"; +import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import type { SessionCatalogTerminalPlan } from "openclaw/plugin-sdk/session-catalog"; +import { CLAUDE_LOCAL_SESSION_HOST_ID } from "./session-catalog-adoption.js"; +import { + CLAUDE_SESSIONS_LIST_COMMAND, + CLAUDE_TERMINAL_RESUME_COMMAND, + ClaudeCatalogParamsError, + isResumableClaudeSource, +} from "./session-catalog-shared.js"; + +export { isResumableClaudeSource } from "./session-catalog-shared.js"; + +type ClaudeTerminalDependencies = { + listClaudeSessions: () => Promise< + Array<{ threadId: string; source?: string; filePath: string; cwd?: string }> + >; + resolveNodeClaudeRecord: (params: { + runtime: OpenClawPluginApi["runtime"]; + nodeId: string; + threadId: string; + }) => Promise<{ source?: string; cwd?: string }>; +}; + +export function isClaudeCliAvailable(pathEnv = process.env.PATH ?? ""): boolean { + return resolveExecutableFromPathEnv("claude", pathEnv) !== undefined; +} + +export function claudeNodeTerminalCapability(node: { + connected?: boolean; + commands?: string[]; + invocableCommands?: string[]; +}): { + canOpenTerminalClaude?: true; +} { + const commands = node.invocableCommands ?? node.commands; + return node.connected === true && commands?.includes(CLAUDE_TERMINAL_RESUME_COMMAND) === true + ? { canOpenTerminalClaude: true } + : {}; +} + +export function isLocalClaudeResumable( + host: { hostId: string }, + source: string | undefined, +): boolean { + return host.hostId === CLAUDE_LOCAL_SESSION_HOST_ID && isResumableClaudeSource(source); +} + +export function canOpenClaudeTerminalSession( + host: { hostId: string; canOpenTerminalClaude?: boolean }, + source: string | undefined, + localCliAvailable: boolean, +): boolean { + return ( + isResumableClaudeSource(source) && + ((host.hostId === CLAUDE_LOCAL_SESSION_HOST_ID && localCliAvailable) || + host.canOpenTerminalClaude === true) + ); +} + +export function terminalEligibility( + host: { hostId: string; canOpenTerminalClaude?: boolean }, + source: string | undefined, + localCliAvailable: boolean, +): { localResumable: boolean; canOpenTerminal: boolean } { + return { + localResumable: isLocalClaudeResumable(host, source), + canOpenTerminal: canOpenClaudeTerminalSession(host, source, localCliAvailable), + }; +} + +export async function openClaudeCatalogTerminal( + params: { + api: OpenClawPluginApi; + hostId: string; + threadId: string; + } & ClaudeTerminalDependencies, +): Promise { + const title = `claude --resume ${params.threadId.slice(0, 8)}…`; + if (params.hostId === CLAUDE_LOCAL_SESSION_HOST_ID) { + const record = (await params.listClaudeSessions()).find( + (candidate) => candidate.threadId === params.threadId, + ); + if (!record || !isResumableClaudeSource(record.source)) { + throw new ClaudeCatalogParamsError("Claude session is unavailable"); + } + const source = await fs.stat(record.filePath).catch(() => undefined); + if (!source?.isFile()) { + throw new ClaudeCatalogParamsError("Claude session transcript is unavailable"); + } + const executable = resolveExecutableFromPathEnv("claude", process.env.PATH ?? ""); + if (!executable) { + throw new ClaudeCatalogParamsError("Claude CLI is unavailable"); + } + return { + kind: "local", + argv: [executable, "--resume", params.threadId], + ...(record.cwd ? { cwd: record.cwd } : {}), + title, + }; + } + if (!params.hostId.startsWith("node:")) { + throw new ClaudeCatalogParamsError("hostId is invalid"); + } + const nodeId = params.hostId.slice("node:".length); + const node = (await params.api.runtime.nodes.list()).nodes.find((candidate) => { + const commands = candidate.invocableCommands ?? candidate.commands; + return ( + candidate.nodeId === nodeId && + candidate.connected === true && + commands?.includes(CLAUDE_SESSIONS_LIST_COMMAND) === true && + commands.includes(CLAUDE_TERMINAL_RESUME_COMMAND) + ); + }); + if (!node) { + throw new ClaudeCatalogParamsError("paired-node Claude terminal is unavailable"); + } + const record = await params.resolveNodeClaudeRecord({ + runtime: params.api.runtime, + nodeId, + threadId: params.threadId, + }); + if (!isResumableClaudeSource(record.source)) { + throw new ClaudeCatalogParamsError("Claude session cannot be resumed in a terminal"); + } + return { + kind: "node", + nodeId, + command: CLAUDE_TERMINAL_RESUME_COMMAND, + paramsJSON: JSON.stringify({ threadId: params.threadId }), + ...(record.cwd ? { cwd: record.cwd } : {}), + title, + }; +} diff --git a/extensions/anthropic/session-catalog-types.ts b/extensions/anthropic/session-catalog-types.ts new file mode 100644 index 000000000000..cf469cc17473 --- /dev/null +++ b/extensions/anthropic/session-catalog-types.ts @@ -0,0 +1,46 @@ +import type { ClaudeTranscriptItem } from "./session-catalog-transcript.js"; + +type ClaudeSessionSource = "claude-cli" | "claude-desktop"; + +export type ClaudeSessionCatalogSession = { + threadId: string; + name?: string | null; + cwd?: string; + status: "stored"; + createdAt?: number; + updatedAt?: number; + recencyAt?: number | null; + source: ClaudeSessionSource; + modelProvider: "anthropic"; + cliVersion?: string; + gitBranch?: string; + archived: false; +}; + +export type ClaudeSessionCatalogPage = { + sessions: ClaudeSessionCatalogSession[]; + nextCursor?: string; +}; + +export type ClaudeSessionCatalogHost = ClaudeSessionCatalogPage & { + hostId: string; + label: string; + kind: "gateway" | "node"; + connected: boolean; + nodeId?: string; + canContinueClaude?: boolean; + canOpenTerminalClaude?: boolean; + error?: { code: string; message: string }; +}; + +export type ClaudeSessionCatalogResult = { + hosts: ClaudeSessionCatalogHost[]; +}; + +export type ClaudeSessionTranscriptPage = { + hostId: string; + label: string; + threadId: string; + items: ClaudeTranscriptItem[]; + nextCursor?: string; +}; diff --git a/extensions/anthropic/session-catalog.test.ts b/extensions/anthropic/session-catalog.test.ts index 7006adabb08e..730a65cd6cfe 100644 --- a/extensions/anthropic/session-catalog.test.ts +++ b/extensions/anthropic/session-catalog.test.ts @@ -7,12 +7,16 @@ import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog"; import { afterEach, describe, expect, it, vi } from "vitest"; import { adoptedSourceKey } from "./session-catalog-adoption.js"; -import { createClaudeSessionNodeHostCommands } from "./session-catalog-node-commands.js"; +import { + createClaudeSessionNodeHostCommands, + createClaudeSessionNodeInvokePolicies, +} from "./session-catalog-node-commands.js"; import { listBoundClaudeSessions } from "./session-catalog-runtime.js"; import { CLAUDE_CLI_NODE_RUN_COMMAND, CLAUDE_SESSIONS_LIST_COMMAND, CLAUDE_SESSION_READ_COMMAND, + CLAUDE_TERMINAL_RESUME_COMMAND, listClaudeSessionCatalog, listLocalClaudeSessionPage, readLocalClaudeTranscriptPage, @@ -21,6 +25,15 @@ import { const homes: string[] = []; const originalHome = process.env.HOME; +const originalPath = process.env.PATH; +const nodeHostMocks = vi.hoisted(() => ({ + runNodePtyCommand: vi.fn(async () => ({ exitCode: 0 })), +})); + +vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => ({ + ...(await importOriginal()), + runNodePtyCommand: nodeHostMocks.runNodePtyCommand, +})); async function createHome(): Promise { const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-claude-catalog-")); @@ -89,7 +102,9 @@ function message( } afterEach(async () => { + nodeHostMocks.runNodePtyCommand.mockClear(); process.env.HOME = originalHome; + process.env.PATH = originalPath; await Promise.all(homes.splice(0).map((home) => fs.rm(home, { recursive: true, force: true }))); }); @@ -467,21 +482,23 @@ describe("Claude session catalog", () => { sessionId: "adopted-node-session", entry: { sessionId: "adopted-node-session", updatedAt: 1 }, })); + const commands = [ + CLAUDE_SESSIONS_LIST_COMMAND, + CLAUDE_SESSION_READ_COMMAND, + CLAUDE_CLI_NODE_RUN_COMMAND, + CLAUDE_TERMINAL_RESUME_COMMAND, + ]; + const authorizedCommands = new Set( + createClaudeSessionNodeInvokePolicies().flatMap((policy) => policy.commands), + ); + expect(authorizedCommands).toEqual(new Set(commands)); const nodes = [ { nodeId: "node-a", displayName: "Node A", connected: true, - commands: [ - CLAUDE_SESSIONS_LIST_COMMAND, - CLAUDE_SESSION_READ_COMMAND, - CLAUDE_CLI_NODE_RUN_COMMAND, - ], - invocableCommands: [ - CLAUDE_SESSIONS_LIST_COMMAND, - CLAUDE_SESSION_READ_COMMAND, - CLAUDE_CLI_NODE_RUN_COMMAND, - ], + commands, + invocableCommands: commands.filter((command) => authorizedCommands.has(command)), }, ]; const invoke = vi.fn(async ({ command }: { command: string }) => { @@ -533,6 +550,15 @@ describe("Claude session catalog", () => { expect(hosts?.[0]?.sessions[0]).toMatchObject({ threadId, canContinue: true, + canOpenTerminal: true, + }); + await expect( + provider?.openTerminal?.({ hostId: "node:node-a", threadId }), + ).resolves.toMatchObject({ + kind: "node", + nodeId: "node-a", + command: CLAUDE_TERMINAL_RESUME_COMMAND, + cwd: "/work/on-node", }); await expect(provider?.continueSession?.({ hostId: "node:node-a", threadId })).resolves.toEqual( { @@ -561,6 +587,18 @@ describe("Claude session catalog", () => { expect(invoke).toHaveBeenCalledWith( expect.objectContaining({ command: CLAUDE_SESSION_READ_COMMAND }), ); + + nodes[0]!.invocableCommands = [ + CLAUDE_SESSIONS_LIST_COMMAND, + CLAUDE_SESSION_READ_COMMAND, + CLAUDE_CLI_NODE_RUN_COMMAND, + ]; + await expect(provider?.list({ hostIds: ["node:node-a"] })).resolves.toMatchObject([ + { sessions: [{ threadId, canOpenTerminal: false }] }, + ]); + await expect(provider?.openTerminal?.({ hostId: "node:node-a", threadId })).rejects.toThrow( + "paired-node Claude terminal is unavailable", + ); }); it("keeps policy-blocked, non-advertising, and Desktop rows view-only", async () => { @@ -830,18 +868,60 @@ describe("Claude session catalog", () => { expect(older.nextCursor).toBeUndefined(); }); - it("registers read-only node commands only when a Claude store exists", async () => { + it("advertises terminal resume only when the store and Claude binary exist", async () => { const home = await createHome(); const commands = createClaudeSessionNodeHostCommands(); expect(commands.map((command) => command.command)).toEqual([ CLAUDE_SESSIONS_LIST_COMMAND, CLAUDE_SESSION_READ_COMMAND, + CLAUDE_TERMINAL_RESUME_COMMAND, ]); expect(commands.every((command) => command.dangerous === false)).toBe(true); + const policy = createClaudeSessionNodeInvokePolicies()[0]; + expect(policy?.commands).toEqual([ + CLAUDE_SESSIONS_LIST_COMMAND, + CLAUDE_SESSION_READ_COMMAND, + CLAUDE_CLI_NODE_RUN_COMMAND, + CLAUDE_TERMINAL_RESUME_COMMAND, + ]); + if (!policy) { + throw new Error("expected Claude node invoke policy"); + } + const invokeNode = vi.fn(async () => ({ ok: true as const, payload: "listed" })); + expect(policy.handle({ command: CLAUDE_TERMINAL_RESUME_COMMAND, invokeNode } as never)).toEqual( + { ok: true }, + ); + expect(invokeNode).not.toHaveBeenCalled(); + await expect( + policy.handle({ command: CLAUDE_SESSIONS_LIST_COMMAND, invokeNode } as never), + ).resolves.toEqual({ ok: true, payload: "listed" }); + expect(invokeNode).toHaveBeenCalledOnce(); const availabilityContext = { config: {}, env: { HOME: home } } as never; expect(commands.every((command) => command.isAvailable?.(availabilityContext))).toBe(false); await fs.mkdir(path.join(home, ".claude", "projects"), { recursive: true }); - expect(commands.every((command) => command.isAvailable?.(availabilityContext))).toBe(true); + expect( + commands.slice(0, 2).every((command) => command.isAvailable?.(availabilityContext)), + ).toBe(true); + expect(commands[2]?.isAvailable?.(availabilityContext)).toBe(false); + const binDir = path.join(home, "bin"); + await fs.mkdir(binDir); + await fs.writeFile(path.join(binDir, "claude"), "#!/bin/sh\n"); + await fs.chmod(path.join(binDir, "claude"), 0o755); + expect( + commands[2]?.isAvailable?.({ config: {}, env: { HOME: home, PATH: binDir } } as never), + ).toBe(true); + + const terminalCommand = commands[2]; + if (!terminalCommand || terminalCommand.duplex !== true) { + throw new Error("expected duplex Claude terminal command"); + } + await expect( + terminalCommand.handle(JSON.stringify({ threadId: "--bad", cols: 80, rows: 24 }), { + signal: new AbortController().signal, + emitChunk: async () => {}, + onInput: () => {}, + }), + ).rejects.toThrow("threadId must be a Claude session id"); const registerSessionCatalog = vi.fn(); const api = { @@ -854,6 +934,113 @@ describe("Claude session catalog", () => { ); }); + it("resolves Claude terminal eligibility and cwd from the node-owned catalog", async () => { + const home = await createHome(); + process.env.HOME = home; + const threadId = "node-owned-session"; + await writeProject({ + home, + entries: [ + { + sessionId: threadId, + fullPath: path.join(home, ".claude", "projects", "-workspace", `${threadId}.jsonl`), + projectPath: "/node/catalog/cwd", + summary: "Node-owned session", + }, + ], + transcripts: { [threadId]: [message(threadId, "user", "hello", 1)] }, + }); + const binDir = path.join(home, "bin"); + await fs.mkdir(binDir); + const executable = path.join(binDir, process.platform === "win32" ? "claude.cmd" : "claude"); + await fs.writeFile(executable, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n"); + if (process.platform !== "win32") { + await fs.chmod(executable, 0o755); + } + process.env.PATH = binDir; + const command = createClaudeSessionNodeHostCommands().find( + (candidate) => candidate.command === CLAUDE_TERMINAL_RESUME_COMMAND, + ); + if (!command || command.duplex !== true) { + throw new Error("expected duplex Claude terminal command"); + } + + await command.handle(JSON.stringify({ threadId, cwd: "/caller/cwd", cols: 80, rows: 24 }), { + signal: new AbortController().signal, + emitChunk: async () => {}, + onInput: () => {}, + }); + + expect(nodeHostMocks.runNodePtyCommand).toHaveBeenCalledWith( + expect.objectContaining({ file: executable, cwd: "/node/catalog/cwd" }), + expect.any(Object), + ); + await expect( + command.handle(JSON.stringify({ threadId: "missing", cols: 80, rows: 24 }), { + signal: new AbortController().signal, + emitChunk: async () => {}, + onInput: () => {}, + }), + ).rejects.toThrow("Claude session cannot be resumed in a terminal"); + }); + + it("requires the Claude binary for local terminal capability and plans", async () => { + const home = await createHome(); + process.env.HOME = home; + const sessionId = "claude-session-1"; + await writeProject({ + home, + entries: [ + { + sessionId, + fullPath: path.join(home, ".claude", "projects", "-workspace", `${sessionId}.jsonl`), + projectPath: home, + summary: "Resume session", + }, + ], + transcripts: { [sessionId]: [message(sessionId, "user", "hello", 1)] }, + }); + const binDir = path.join(home, "bin"); + await fs.mkdir(binDir); + process.env.PATH = binDir; + let provider: SessionCatalogProvider | undefined; + registerClaudeSessionCatalog({ + id: "anthropic", + config: {}, + runtime: { + config: { current: () => ({}) }, + nodes: { list: async () => ({ nodes: [] }) }, + agent: { session: { listSessionEntries: () => [] } }, + }, + registerSessionCatalog: (candidate: SessionCatalogProvider) => { + provider = candidate; + }, + } as unknown as OpenClawPluginApi); + + await expect(provider?.list({})).resolves.toMatchObject([ + { sessions: [{ threadId: sessionId, canOpenTerminal: false }] }, + ]); + await expect( + provider?.openTerminal?.({ hostId: "gateway:local", threadId: sessionId }), + ).rejects.toThrow("Claude CLI is unavailable"); + + await fs.writeFile(path.join(binDir, "claude"), "#!/bin/sh\n"); + await fs.chmod(path.join(binDir, "claude"), 0o755); + await expect(provider?.list({})).resolves.toMatchObject([ + { sessions: [{ threadId: sessionId, canOpenTerminal: true }] }, + ]); + await expect( + provider?.openTerminal?.({ hostId: "gateway:local", threadId: sessionId }), + ).resolves.toMatchObject({ + kind: "local", + argv: [path.join(binDir, "claude"), "--resume", sessionId], + cwd: home, + }); + await expect( + provider?.openTerminal?.({ hostId: "gateway:local", threadId: "missing" }), + ).rejects.toThrow("Claude session is unavailable"); + }); + it("keeps one failed node isolated from healthy hosts", async () => { const runtime = { nodes: { diff --git a/extensions/anthropic/session-catalog.ts b/extensions/anthropic/session-catalog.ts index 86542cc40cd8..5e67fabbfa81 100644 --- a/extensions/anthropic/session-catalog.ts +++ b/extensions/anthropic/session-catalog.ts @@ -13,10 +13,6 @@ import type { } from "openclaw/plugin-sdk/session-catalog"; import { withSessionTranscriptWriteLock } from "openclaw/plugin-sdk/session-transcript-runtime"; import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; - -export const CLAUDE_SESSIONS_LIST_COMMAND = "anthropic.claude.sessions.list.v1"; -export const CLAUDE_SESSION_READ_COMMAND = "anthropic.claude.sessions.read.v1"; -export const CLAUDE_CLI_NODE_RUN_COMMAND = "agent.cli.claude.run.v1"; import { CLAUDE_CLI_BACKEND_ID, CLAUDE_CLI_DEFAULT_MODEL_REF } from "./cli-constants.js"; import { adoptedSessionKey, @@ -28,13 +24,36 @@ import { listBoundClaudeSessions, resolveClaudeCatalogCreateSession, } from "./session-catalog-runtime.js"; +import { + CLAUDE_CLI_NODE_RUN_COMMAND, + CLAUDE_SESSION_READ_COMMAND, + CLAUDE_SESSIONS_LIST_COMMAND, + ClaudeCatalogParamsError, + isResumableClaudeSource, +} from "./session-catalog-shared.js"; +import * as catalogTerminal from "./session-catalog-terminal.js"; import { collectTranscriptText, parseTranscriptLine, type ClaudeTranscriptItem, } from "./session-catalog-transcript.js"; +import type { + ClaudeSessionCatalogHost, + ClaudeSessionCatalogPage, + ClaudeSessionCatalogResult, + ClaudeSessionCatalogSession, + ClaudeSessionTranscriptPage, +} from "./session-catalog-types.js"; export type { ClaudeTranscriptItem } from "./session-catalog-transcript.js"; +export type { + ClaudeSessionCatalogHost, + ClaudeSessionCatalogPage, + ClaudeSessionCatalogResult, + ClaudeSessionCatalogSession, + ClaudeSessionTranscriptPage, +} from "./session-catalog-types.js"; +export * from "./session-catalog-shared.js"; const DEFAULT_PAGE_LIMIT = 50; const MAX_PAGE_LIMIT = 100; @@ -57,57 +76,6 @@ const NODE_INVOKE_TIMEOUT_MS = 30_000; const CLAUDE_HISTORY_IMPORT_MAX_ITEMS = 200; const CLAUDE_HISTORY_IMPORT_MAX_BYTES = 512 * 1024; -type ClaudeSessionSource = "claude-cli" | "claude-desktop"; - -// Desktop-app sessions live in the same ~/.claude/projects store as CLI -// sessions (records without a projects JSONL are dropped during discovery), -// so `claude --resume --fork-session` continues both alike. -function isResumableClaudeSource(source: string | undefined): boolean { - return source === "claude-cli" || source === "claude-desktop"; -} - -export type ClaudeSessionCatalogSession = { - threadId: string; - name?: string | null; - cwd?: string; - status: "stored"; - createdAt?: number; - updatedAt?: number; - recencyAt?: number | null; - source: ClaudeSessionSource; - modelProvider: "anthropic"; - cliVersion?: string; - gitBranch?: string; - archived: false; -}; - -export type ClaudeSessionCatalogPage = { - sessions: ClaudeSessionCatalogSession[]; - nextCursor?: string; -}; - -export type ClaudeSessionCatalogHost = ClaudeSessionCatalogPage & { - hostId: string; - label: string; - kind: "gateway" | "node"; - connected: boolean; - nodeId?: string; - canContinueClaude?: boolean; - error?: { code: string; message: string }; -}; - -export type ClaudeSessionCatalogResult = { - hosts: ClaudeSessionCatalogHost[]; -}; - -export type ClaudeSessionTranscriptPage = { - hostId: string; - label: string; - threadId: string; - items: ClaudeTranscriptItem[]; - nextCursor?: string; -}; - type SessionIndexEntry = { sessionId?: unknown; fullPath?: unknown; @@ -138,8 +106,6 @@ type CatalogRecord = ClaudeSessionCatalogSession & { filePath: string; }; -class ClaudeCatalogParamsError extends Error {} - function optionalString(value: unknown, maxLength = MAX_STRING_LENGTH): string | undefined { if (typeof value !== "string") { return undefined; @@ -481,7 +447,7 @@ async function discoverCliRecords( } } -async function listClaudeSessions(homeDir = currentHomeDir()): Promise { +export async function listClaudeSessions(homeDir = currentHomeDir()): Promise { const [indexed, desktop] = await Promise.all([ readIndexRecords(homeDir), readDesktopMetadata(homeDir), @@ -973,6 +939,7 @@ export async function listClaudeSessionCatalog(params: { node.invocableCommands?.includes(CLAUDE_SESSIONS_LIST_COMMAND) === true && node.invocableCommands.includes(CLAUDE_SESSION_READ_COMMAND) && node.invocableCommands.includes(CLAUDE_CLI_NODE_RUN_COMMAND), + ...catalogTerminal.claudeNodeTerminalCapability(node), }; if (node.connected !== true) { return Object.assign(common, { @@ -1167,7 +1134,7 @@ async function importClaudeHistory(params: { const claudeContinueOperations = new Map>(); -async function resolveNodeClaudeRecord(params: { +export async function resolveNodeClaudeRecord(params: { runtime: PluginRuntime; nodeId: string; threadId: string; @@ -1340,6 +1307,7 @@ function toGenericClaudeItem(item: ClaudeTranscriptItem): SessionCatalogTranscri function toGenericClaudeHost( host: ClaudeSessionCatalogHost, adopted: ReadonlyMap, + cliAvailable: boolean, ): SessionCatalogHost { return { hostId: host.hostId, @@ -1348,16 +1316,14 @@ function toGenericClaudeHost( connected: host.connected, ...(host.nodeId ? { nodeId: host.nodeId } : {}), sessions: host.sessions.map((session) => { - const localResumable = - host.hostId === CLAUDE_LOCAL_SESSION_HOST_ID && isResumableClaudeSource(session.source); + const terminal = catalogTerminal.terminalEligibility(host, session.source, cliAvailable); const nodeCli = host.kind === "node" && host.canContinueClaude === true && session.source === "claude-cli"; const existingSessionKey = adopted.get(adoptedSourceKey(host.hostId, session.threadId)); // Already-adopted rows stay continuable even if node policy later denies // the run command: continue only returns the existing session key, and // the turn itself still fails closed at invoke time. - const continuable = localResumable || nodeCli || Boolean(existingSessionKey); - const openClawSessionKey = continuable ? existingSessionKey : undefined; + const continuable = terminal.localResumable || nodeCli || Boolean(existingSessionKey); return { threadId: session.threadId, ...(session.name ? { name: session.name } : {}), @@ -1371,9 +1337,10 @@ function toGenericClaudeHost( ...(session.cliVersion ? { cliVersion: session.cliVersion } : {}), ...(session.gitBranch ? { gitBranch: session.gitBranch } : {}), archived: session.archived, - ...(openClawSessionKey ? { openClawSessionKey } : {}), + ...(continuable && existingSessionKey ? { openClawSessionKey: existingSessionKey } : {}), canContinue: continuable, canArchive: false, + canOpenTerminal: terminal.canOpenTerminal, }; }), ...(host.nextCursor ? { nextCursor: host.nextCursor } : {}), @@ -1388,8 +1355,9 @@ export function registerClaudeSessionCatalog(api: OpenClawPluginApi): void { resolveCreateSession: ({ agentId }) => resolveClaudeCatalogCreateSession(api, agentId), list: async (query) => { const adopted = listBoundClaudeSessions(api); + const localCliAvailable = catalogTerminal.isClaudeCliAvailable(); const result = await listClaudeSessionCatalog({ runtime: api.runtime, query }); - return result.hosts.map((host) => toGenericClaudeHost(host, adopted)); + return result.hosts.map((host) => toGenericClaudeHost(host, adopted, localCliAvailable)); }, read: async (request) => { const page = await readClaudeSessionTranscript({ @@ -1403,6 +1371,13 @@ export function registerClaudeSessionCatalog(api: OpenClawPluginApi): void { }, continueSession: async (request) => await continueClaudeSession(api, request.hostId, request.threadId), + openTerminal: (request) => + catalogTerminal.openClaudeCatalogTerminal({ + api, + ...request, + listClaudeSessions, + resolveNodeClaudeRecord, + }), }; api.registerSessionCatalog(provider); } diff --git a/extensions/codex/src/session-catalog-node-continue.ts b/extensions/codex/src/session-catalog-node-continue.ts index eadd21970933..f0ed3230e9c1 100644 --- a/extensions/codex/src/session-catalog-node-continue.ts +++ b/extensions/codex/src/session-catalog-node-continue.ts @@ -20,11 +20,14 @@ import { import { catalogError, CatalogParamsError, + CODEX_APP_SERVER_THREADS_LIST_COMMAND, + CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND, CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT, filterCatalogPageByTitle, isInteractiveThreadSource, MAX_ACTION_CATALOG_PAGES, MAX_TRANSCRIPT_PAGE_LIMIT, + NODE_INVOKE_TIMEOUT_MS, parseCatalogPage, parseTranscriptPage, unwrapNodeInvokePayload, @@ -35,13 +38,6 @@ import type { CodexSessionCatalogSession, } from "./session-catalog-types.js"; -export const CODEX_APP_SERVER_THREADS_LIST_COMMAND = "codex.appServer.threads.list.v1"; -export const CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND = "codex.appServer.thread.turns.list.v1"; - -// A node may need a cold Codex state scan before returning a large catalog page. -// Keep this above the Mac node's native 60-second deadline so its specific -// timeout wins instead of the less useful generic node.invoke timeout. -export const NODE_INVOKE_TIMEOUT_MS = 65_000; const CODEX_NODE_CONTINUE_COMMANDS = [ CODEX_APP_SERVER_THREADS_LIST_COMMAND, CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND, diff --git a/extensions/codex/src/session-catalog-parsing.ts b/extensions/codex/src/session-catalog-parsing.ts index 04e05f0ae738..584a9fee4458 100644 --- a/extensions/codex/src/session-catalog-parsing.ts +++ b/extensions/codex/src/session-catalog-parsing.ts @@ -11,7 +11,13 @@ import type { } from "./session-catalog-types.js"; const DEFAULT_PAGE_LIMIT = 50; +export const CODEX_APP_SERVER_THREADS_CAPABILITY = "codex-app-server-threads"; +export const CODEX_APP_SERVER_THREADS_LIST_COMMAND = "codex.appServer.threads.list.v1"; +export const CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND = "codex.appServer.thread.turns.list.v1"; +export const CODEX_LOCAL_SESSION_HOST_ID = "gateway:local"; export const CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT = 100; +// Cold Codex state scans can outlive the Mac node's native 60-second deadline. +export const NODE_INVOKE_TIMEOUT_MS = 65_000; const MAX_SEARCH_LENGTH = 500; export const MAX_CURSOR_LENGTH = 4096; const MAX_CURSOR_COUNT = 100; diff --git a/extensions/codex/src/session-catalog-terminal.ts b/extensions/codex/src/session-catalog-terminal.ts new file mode 100644 index 000000000000..6d08cd4c24ff --- /dev/null +++ b/extensions/codex/src/session-catalog-terminal.ts @@ -0,0 +1,218 @@ +// Codex catalog terminal ownership: validated resume commands and terminal plans. +import { + decodeNodePtyResumeParams, + resolveExecutableFromPathEnv, + runNodePtyCommand, +} from "openclaw/plugin-sdk/node-host"; +import type { + OpenClawPluginApi, + OpenClawPluginNodeHostCommand, +} from "openclaw/plugin-sdk/plugin-entry"; +import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; +import type { SessionCatalogTerminalPlan } from "openclaw/plugin-sdk/session-catalog"; +import { + CatalogParamsError, + CODEX_APP_SERVER_THREADS_CAPABILITY, + CODEX_APP_SERVER_THREADS_LIST_COMMAND, + CODEX_LOCAL_SESSION_HOST_ID, + CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT, + isInteractiveThreadSource, + MAX_ACTION_CATALOG_PAGES, + NODE_INVOKE_TIMEOUT_MS, + unwrapNodeInvokePayload, +} from "./session-catalog-parsing.js"; +import type { + CodexSessionCatalogControl, + CodexSessionCatalogPage, + CodexSessionCatalogSession, +} from "./session-catalog-types.js"; + +export const CODEX_TERMINAL_RESUME_COMMAND = "codex.terminal.resume.v1"; + +export function resolveLocalCodexTerminalExecutable( + pathEnv = process.env.PATH ?? "", +): string | undefined { + return resolveExecutableFromPathEnv("codex", pathEnv); +} + +export function codexNodeTerminalCapability(node: { + connected?: boolean; + commands?: string[]; + invocableCommands?: string[]; +}): { canOpenTerminalCodex?: true } { + const commands = node.invocableCommands ?? node.commands; + return node.connected === true && commands?.includes(CODEX_TERMINAL_RESUME_COMMAND) === true + ? { canOpenTerminalCodex: true } + : {}; +} + +export async function requireCatalogEligibleThread( + control: CodexSessionCatalogControl, + threadId: string, +): Promise { + let cursor: string | undefined; + const seenCursors = new Set(); + for (let pageIndex = 0; pageIndex < MAX_ACTION_CATALOG_PAGES; pageIndex += 1) { + const page = await control.listPage({ + limit: CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT, + ...(cursor ? { cursor } : {}), + }); + const candidate = page.sessions.find((session) => session.threadId === threadId); + if (candidate) { + if (candidate.source === "cli" || candidate.source === "vscode") { + return candidate; + } + throw new CatalogParamsError( + "Codex session is not a non-archived interactive CLI or VS Code session", + ); + } + const nextCursor = page.nextCursor?.trim(); + if (!nextCursor) { + throw new CatalogParamsError( + "Codex session is not a non-archived interactive CLI or VS Code session", + ); + } + if (seenCursors.has(nextCursor)) { + throw new CatalogParamsError("Codex session eligibility could not be verified"); + } + seenCursors.add(nextCursor); + cursor = nextCursor; + } + throw new CatalogParamsError("Codex session eligibility could not be verified"); +} + +export function createCodexTerminalNodeHostCommand( + control: CodexSessionCatalogControl, +): OpenClawPluginNodeHostCommand { + return { + command: CODEX_TERMINAL_RESUME_COMMAND, + cap: CODEX_APP_SERVER_THREADS_CAPABILITY, + dangerous: false, + duplex: true, + isAvailable: ({ env }) => Boolean(resolveExecutableFromPathEnv("codex", env.PATH ?? "")), + handle: async (paramsJSON, io) => { + if (!io) { + throw new Error("Codex terminal command requires duplex transport"); + } + const resume = decodeNodePtyResumeParams(paramsJSON, (value) => { + if ( + typeof value !== "string" || + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu.test(value) + ) { + throw new CatalogParamsError("threadId must be a UUID"); + } + return value; + }); + const record = await requireCatalogEligibleThread(control, resume.threadId); + const file = resolveExecutableFromPathEnv("codex", process.env.PATH ?? ""); + if (!file) { + throw new Error("Codex CLI is unavailable"); + } + return JSON.stringify( + await runNodePtyCommand( + { + file, + args: ["resume", resume.threadId], + cwd: record.cwd, + cols: resume.cols, + rows: resume.rows, + }, + io, + ), + ); + }, + }; +} + +async function resolveNodeCatalogEligibleThread(params: { + runtime: PluginRuntime; + nodeId: string; + threadId: string; + parseCatalogPage: (value: unknown) => CodexSessionCatalogPage; +}): Promise { + let cursor: string | undefined; + const seenCursors = new Set(); + for (let pageIndex = 0; pageIndex < MAX_ACTION_CATALOG_PAGES; pageIndex += 1) { + const raw = await params.runtime.nodes.invoke({ + nodeId: params.nodeId, + command: CODEX_APP_SERVER_THREADS_LIST_COMMAND, + params: { + limit: CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT, + ...(cursor ? { cursor } : {}), + }, + timeoutMs: NODE_INVOKE_TIMEOUT_MS, + }); + const page = params.parseCatalogPage(unwrapNodeInvokePayload(raw)); + const record = page.sessions.find((candidate) => candidate.threadId === params.threadId); + if (record) { + if (isInteractiveThreadSource(record.source)) { + return record; + } + break; + } + const nextCursor = page.nextCursor?.trim(); + if (!nextCursor || seenCursors.has(nextCursor)) { + break; + } + seenCursors.add(nextCursor); + cursor = nextCursor; + } + throw new CatalogParamsError( + "Codex session is not a non-archived interactive CLI or VS Code session", + ); +} + +export async function openCodexCatalogTerminal(params: { + api: OpenClawPluginApi; + control: CodexSessionCatalogControl; + hostId: string; + threadId: string; + parseCatalogPage: (value: unknown) => CodexSessionCatalogPage; +}): Promise { + const title = `codex resume ${params.threadId.slice(0, 8)}…`; + if (params.hostId === CODEX_LOCAL_SESSION_HOST_ID) { + const record = await requireCatalogEligibleThread(params.control, params.threadId); + const executable = resolveLocalCodexTerminalExecutable(); + // A managed app-server may exist without a local CLI. Fail closed so + // terminal resume never targets a different machine or missing binary. + if (!executable) { + throw new CatalogParamsError("Codex CLI is unavailable"); + } + return { + kind: "local", + argv: [executable, "resume", params.threadId], + ...(record.cwd ? { cwd: record.cwd } : {}), + title, + }; + } + if (!params.hostId.startsWith("node:")) { + throw new CatalogParamsError("hostId is invalid"); + } + const nodeId = params.hostId.slice("node:".length); + const node = (await params.api.runtime.nodes.list()).nodes.find((candidate) => { + const commands = candidate.invocableCommands ?? candidate.commands; + return ( + candidate.nodeId === nodeId && + candidate.connected === true && + commands?.includes(CODEX_APP_SERVER_THREADS_LIST_COMMAND) === true && + commands.includes(CODEX_TERMINAL_RESUME_COMMAND) + ); + }); + if (!node) { + throw new CatalogParamsError("paired-node Codex terminal is unavailable"); + } + const record = await resolveNodeCatalogEligibleThread({ + runtime: params.api.runtime, + nodeId, + threadId: params.threadId, + parseCatalogPage: params.parseCatalogPage, + }); + return { + kind: "node", + nodeId, + command: CODEX_TERMINAL_RESUME_COMMAND, + paramsJSON: JSON.stringify({ threadId: params.threadId }), + ...(record.cwd ? { cwd: record.cwd } : {}), + title, + }; +} diff --git a/extensions/codex/src/session-catalog-types.ts b/extensions/codex/src/session-catalog-types.ts index fe932d10a228..c72b86ad9405 100644 --- a/extensions/codex/src/session-catalog-types.ts +++ b/extensions/codex/src/session-catalog-types.ts @@ -1,3 +1,11 @@ +import type { + CodexThread, + CodexThreadListParams, + CodexThreadListResponse, + CodexThreadTurnsListParams, + CodexThreadTurnsListResponse, +} from "./app-server/protocol.js"; + /** Read-only metadata for one Codex app-server thread. */ export type CodexSessionCatalogSession = { threadId: string; @@ -31,6 +39,16 @@ export type CodexSessionCatalogPageParams = { cwd?: string; }; +export type CodexSessionCatalogControl = { + connectionFingerprint?: string; + withPinnedConnection(run: (control: CodexSessionCatalogControl) => Promise): Promise; + listPage(params: CodexSessionCatalogPageParams): Promise; + listDescendantPage(params: CodexThreadListParams): Promise; + listTurnPage(params: CodexThreadTurnsListParams): Promise; + readThread(threadId: string, includeTurns?: boolean): Promise; + archiveThread(threadId: string): Promise; +}; + export type CodexSessionCatalogError = { code: string; message: string; @@ -43,6 +61,7 @@ export type CodexSessionCatalogHost = { connected: boolean; nodeId?: string; canContinueCodex?: boolean; + canOpenTerminalCodex?: boolean; sessions: CodexSessionCatalogSession[]; nextCursor?: string; backwardsCursor?: string; diff --git a/extensions/codex/src/session-catalog.test.ts b/extensions/codex/src/session-catalog.test.ts index 70dc70a31aed..0d26814fe26d 100644 --- a/extensions/codex/src/session-catalog.test.ts +++ b/extensions/codex/src/session-catalog.test.ts @@ -1,12 +1,15 @@ // Codex supervision tests cover passive listing and safe local session takeover. /* oxlint-disable typescript/unbound-method -- assertions inspect vi.fn-backed object methods, not unbound class methods. */ import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import type { PluginRuntime } from "openclaw/plugin-sdk/plugin-runtime"; import type { SessionCatalogProvider } from "openclaw/plugin-sdk/session-catalog"; import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { CodexThread } from "./app-server/protocol.js"; import { sessionBindingIdentity } from "./app-server/session-binding.js"; import { @@ -16,9 +19,11 @@ import { } from "./app-server/session-binding.test-helpers.js"; import { CODEX_LOCAL_SESSION_HOST_ID, + CODEX_TERMINAL_RESUME_COMMAND, codexSessionCatalogRuntime, createCodexSessionCatalogControl, createCodexSessionCatalogNodeHostCommands, + createCodexSessionCatalogNodeInvokePolicies, } from "./session-catalog.js"; const CODEX_APP_SERVER_THREADS_LIST_COMMAND = "codex.appServer.threads.list.v1"; @@ -30,6 +35,8 @@ const CODEX_NODE_CONTINUE_COMMANDS = [ CODEX_CLI_SESSION_RESUME_COMMAND, ] as const; type CodexSessionCatalogControl = ReturnType; +const originalPath = process.env.PATH; +const tempDirs: string[] = []; const archiveLocalCodexSession = codexSessionCatalogRuntime.archiveLocal; const continueLocalCodexSession = codexSessionCatalogRuntime.continueLocal; @@ -52,6 +59,9 @@ const transcriptMirrorMocks = vi.hoisted(() => ({ omittedMessages: 0, })), })); +const nodeHostMocks = vi.hoisted(() => ({ + runNodePtyCommand: vi.fn(async () => ({ exitCode: 0 })), +})); vi.mock("./command-rpc.js", () => ({ codexControlRequest: commandRpcMocks.codexControlRequest, @@ -66,6 +76,10 @@ vi.mock("./app-server/shared-client.js", () => ({ vi.mock("./app-server/transcript-mirror.js", () => ({ importCodexThreadHistoryToTranscript: transcriptMirrorMocks.importCodexThreadHistoryToTranscript, })); +vi.mock("openclaw/plugin-sdk/node-host", async (importOriginal) => ({ + ...(await importOriginal()), + runNodePtyCommand: nodeHostMocks.runNodePtyCommand, +})); type CreateSessionEntryParams = Parameters< PluginRuntime["agent"]["session"]["createSessionEntry"] @@ -337,6 +351,7 @@ function createGatewayApi(runtime: PluginRuntime) { } beforeEach(() => { + nodeHostMocks.runNodePtyCommand.mockClear(); commandRpcMocks.codexControlRequest.mockReset(); pinnedConnectionMocks.getClient.mockReset(); pinnedConnectionMocks.getClient.mockResolvedValue(pinnedConnectionMocks.client); @@ -349,6 +364,11 @@ beforeEach(() => { }); }); +afterEach(async () => { + process.env.PATH = originalPath; + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + describe("Codex supervision catalog", () => { it("lists non-archived interactive threads without probing transcript previews", async () => { const pluginConfig = { supervision: { enabled: true } }; @@ -972,6 +992,64 @@ describe("Codex supervision catalog", () => { }); }); + it("rejects malformed terminal resume thread ids before spawning", async () => { + const command = createCodexSessionCatalogNodeHostCommands(createEligibleControl()).find( + (candidate) => candidate.command === CODEX_TERMINAL_RESUME_COMMAND, + ); + if (!command || command.duplex !== true) { + throw new Error("Codex terminal command was not registered as duplex"); + } + await expect( + command.handle(JSON.stringify({ threadId: "not-a-uuid", cols: 80, rows: 24 }), { + signal: new AbortController().signal, + emitChunk: async () => {}, + onInput: () => {}, + }), + ).rejects.toThrow("threadId must be a UUID"); + }); + + it("resolves node terminal eligibility and cwd from the node-owned catalog record", async () => { + const threadId = "123e4567-e89b-12d3-a456-426614174000"; + const binDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-node-terminal-")); + tempDirs.push(binDir); + const executable = path.join(binDir, process.platform === "win32" ? "codex.cmd" : "codex"); + await fs.writeFile(executable, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n"); + if (process.platform !== "win32") { + await fs.chmod(executable, 0o755); + } + process.env.PATH = binDir; + const command = createCodexSessionCatalogNodeHostCommands( + createEligibleControl({ + listPage: vi.fn(async () => ({ + sessions: [ + { + threadId, + status: "idle", + source: "cli", + cwd: "/node/catalog/cwd", + archived: false, + }, + ], + })), + }), + ).find((candidate) => candidate.command === CODEX_TERMINAL_RESUME_COMMAND); + if (!command || command.duplex !== true) { + throw new Error("Codex terminal command was not registered as duplex"); + } + + await command.handle(JSON.stringify({ threadId, cwd: "/caller/cwd", cols: 80, rows: 24 }), { + signal: new AbortController().signal, + emitChunk: async () => {}, + onInput: () => {}, + }); + + expect(command.dangerous).toBe(false); + expect(nodeHostMocks.runNodePtyCommand).toHaveBeenCalledWith( + expect.objectContaining({ file: executable, cwd: "/node/catalog/cwd" }), + expect.any(Object), + ); + }); + it("rejects an oversized transcript page before returning it over node.invoke", async () => { const control = createEligibleControl({ listTurnPage: vi.fn(async () => ({ @@ -2976,6 +3054,125 @@ describe("Codex supervision actions", () => { expect(createSessionEntry).not.toHaveBeenCalled(); }); + it("builds local and paired-node terminal plans from verified catalog records", async () => { + const threadId = "123e4567-e89b-12d3-a456-426614174000"; + const binDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-terminal-")); + tempDirs.push(binDir); + process.env.PATH = binDir; + const executable = path.join(binDir, process.platform === "win32" ? "codex.cmd" : "codex"); + const control = createEligibleControl({ + listPage: vi.fn(async () => ({ + sessions: [ + { threadId, status: "active", source: "cli", cwd: "/workspace/local", archived: false }, + ], + })), + }); + const invoke = vi.fn(async (request) => ({ + payloadJSON: JSON.stringify({ + sessions: + // The node thread lookup must page without a title searchTerm; if a + // regression ever sends one, this returns [] and the test fails. + typeof (request.params as { searchTerm?: string } | undefined)?.searchTerm === "string" + ? [] + : [ + { + threadId, + name: "Normal project title", + status: "active", + source: "vscode", + cwd: "/workspace/node", + archived: false, + }, + ], + }), + })); + const commands = [CODEX_APP_SERVER_THREADS_LIST_COMMAND, CODEX_TERMINAL_RESUME_COMMAND]; + const authorizedCommands = new Set( + createCodexSessionCatalogNodeInvokePolicies().flatMap((policy) => policy.commands), + ); + expect(authorizedCommands).toContain(CODEX_TERMINAL_RESUME_COMMAND); + const policy = createCodexSessionCatalogNodeInvokePolicies()[0]; + if (!policy) { + throw new Error("expected Codex node invoke policy"); + } + const invokeNode = vi.fn(async () => ({ ok: true as const, payload: "listed" })); + expect(policy.handle({ command: CODEX_TERMINAL_RESUME_COMMAND, invokeNode } as never)).toEqual({ + ok: true, + }); + expect(invokeNode).not.toHaveBeenCalled(); + const node = { + nodeId: "devbox", + connected: true, + commands, + invocableCommands: commands.filter((command) => authorizedCommands.has(command)), + }; + const { runtime } = createRuntime({ nodes: [node], invoke }); + const { api, getProvider } = createGatewayApi(runtime); + registerCodexSessionCatalog({ + api, + bindingStore: createCodexTestBindingStore(), + control, + getRuntimeConfig: () => config, + }); + + await expect(getProvider()?.list({})).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + hostId: CODEX_LOCAL_SESSION_HOST_ID, + sessions: [expect.objectContaining({ threadId, canOpenTerminal: false })], + }), + expect.objectContaining({ + hostId: "node:devbox", + sessions: [expect.objectContaining({ threadId, canOpenTerminal: true })], + }), + ]), + ); + await expect( + getProvider()?.openTerminal?.({ hostId: CODEX_LOCAL_SESSION_HOST_ID, threadId }), + ).rejects.toThrow("Codex CLI is unavailable"); + + await fs.writeFile(executable, process.platform === "win32" ? "@echo off\r\n" : "#!/bin/sh\n"); + if (process.platform !== "win32") { + await fs.chmod(executable, 0o755); + } + await expect(getProvider()?.list({})).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + hostId: CODEX_LOCAL_SESSION_HOST_ID, + sessions: [expect.objectContaining({ threadId, canOpenTerminal: true })], + }), + expect.objectContaining({ + hostId: "node:devbox", + sessions: [expect.objectContaining({ threadId, canOpenTerminal: true })], + }), + ]), + ); + await expect( + getProvider()?.openTerminal?.({ hostId: CODEX_LOCAL_SESSION_HOST_ID, threadId }), + ).resolves.toMatchObject({ + kind: "local", + argv: [executable, "resume", threadId], + cwd: "/workspace/local", + }); + await expect( + getProvider()?.openTerminal?.({ hostId: "node:devbox", threadId }), + ).resolves.toMatchObject({ + kind: "node", + nodeId: "devbox", + command: CODEX_TERMINAL_RESUME_COMMAND, + cwd: "/workspace/node", + }); + expect(invoke.mock.calls.at(-1)?.[0].params).not.toHaveProperty("searchTerm"); + + node.invocableCommands = [CODEX_APP_SERVER_THREADS_LIST_COMMAND]; + await expect(getProvider()?.list({ hostIds: ["node:devbox"] })).resolves.toMatchObject([ + { sessions: [{ threadId, canOpenTerminal: false }] }, + ]); + await expect( + getProvider()?.openTerminal?.({ hostId: "node:devbox", threadId }), + ).rejects.toThrow("paired-node Codex terminal is unavailable"); + }); + it("marks not-loaded local interactive sessions as actionable", async () => { const { runtime } = createRuntime(); const { api, getProvider } = createGatewayApi(runtime); diff --git a/extensions/codex/src/session-catalog.ts b/extensions/codex/src/session-catalog.ts index 647a9f6a4476..b9cebe79eb58 100644 --- a/extensions/codex/src/session-catalog.ts +++ b/extensions/codex/src/session-catalog.ts @@ -52,28 +52,28 @@ import { type CodexSessionDisposition, } from "./session-catalog-node-adoption.js"; import { - CODEX_APP_SERVER_THREADS_LIST_COMMAND, - CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND, compareNodeLabels, continueNodeCodexSession, listPairedNode, nodeLabel, - NODE_INVOKE_TIMEOUT_MS, type CatalogNode, } from "./session-catalog-node-continue.js"; import { catalogError, CatalogParamsError, - CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT, + CODEX_APP_SERVER_THREADS_CAPABILITY, + CODEX_APP_SERVER_THREADS_LIST_COMMAND, + CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND, + CODEX_LOCAL_SESSION_HOST_ID, DEFAULT_TRANSCRIPT_PAGE_LIMIT, filterCatalogPageByTitle, isInteractiveThreadSource, - MAX_ACTION_CATALOG_PAGES, MAX_CURSOR_LENGTH, MAX_HOST_COUNT, MAX_SESSION_ID_LENGTH, MAX_TITLE_SEARCH_CATALOG_PAGES, MAX_TRANSCRIPT_PAGE_LIMIT, + NODE_INVOKE_TIMEOUT_MS, normalizeLimit, parseCatalogPage, parseJsonParams, @@ -86,32 +86,30 @@ import { toCatalogSession, unwrapNodeInvokePayload, } from "./session-catalog-parsing.js"; +import { + CODEX_TERMINAL_RESUME_COMMAND, + codexNodeTerminalCapability, + createCodexTerminalNodeHostCommand, + openCodexCatalogTerminal, + requireCatalogEligibleThread, + resolveLocalCodexTerminalExecutable, +} from "./session-catalog-terminal.js"; import type { + CodexSessionCatalogControl, CodexSessionCatalogHost, - CodexSessionCatalogPage, - CodexSessionCatalogPageParams, CodexSessionCatalogParams, CodexSessionCatalogResult, CodexSessionCatalogSession, CodexSessionTranscriptPage, } from "./session-catalog-types.js"; -const CODEX_APP_SERVER_THREADS_CAPABILITY = "codex-app-server-threads"; const CODEX_SUPERVISION_SESSION_KEY_PREFIX = "harness:codex:supervision:"; -export { CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT } from "./session-catalog-parsing.js"; - -export const CODEX_LOCAL_SESSION_HOST_ID = "gateway:local"; - -type CodexSessionCatalogControl = { - connectionFingerprint?: string; - withPinnedConnection(run: (control: CodexSessionCatalogControl) => Promise): Promise; - listPage(params: CodexSessionCatalogPageParams): Promise; - listDescendantPage(params: CodexThreadListParams): Promise; - listTurnPage(params: CodexThreadTurnsListParams): Promise; - readThread(threadId: string, includeTurns?: boolean): Promise; - archiveThread(threadId: string): Promise; -}; +export { + CODEX_LOCAL_SESSION_HOST_ID, + CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT, +} from "./session-catalog-parsing.js"; +export { CODEX_TERMINAL_RESUME_COMMAND } from "./session-catalog-terminal.js"; type CodexSessionCatalogRequestSnapshot = { requestTimeoutMs: number; @@ -433,14 +431,15 @@ async function listCodexSessionCatalog(params: { config: params.config, runtime: params.runtime, }); - const nodeHosts = nodes.toSorted(compareNodeLabels).map((node) => - listPairedNode({ + const nodeHosts = nodes.toSorted(compareNodeLabels).map(async (node) => { + const host = await listPairedNode({ runtime: params.runtime, node, query, adoptedSessions: adoptedNodeSessions, - }), - ); + }); + return Object.assign(host, codexNodeTerminalCapability(node)); + }); return { hosts: await Promise.all([...localHosts, ...nodeHosts]) }; } @@ -493,6 +492,7 @@ export function createCodexSessionCatalogNodeHostCommands( } }, }, + createCodexTerminalNodeHostCommand(control), ]; } @@ -615,41 +615,6 @@ function requireIdleThread(thread: CodexThread, action: "continue" | "archive"): ); } -async function requireCatalogEligibleThread( - control: CodexSessionCatalogControl, - threadId: string, -): Promise { - let cursor: string | undefined; - const seenCursors = new Set(); - for (let pageIndex = 0; pageIndex < MAX_ACTION_CATALOG_PAGES; pageIndex += 1) { - const page = await control.listPage({ - limit: CODEX_SESSION_CATALOG_MAX_PAGE_LIMIT, - ...(cursor ? { cursor } : {}), - }); - const candidate = page.sessions.find((session) => session.threadId === threadId); - if (candidate) { - if (candidate.source === "cli" || candidate.source === "vscode") { - return; - } - throw new CatalogParamsError( - "Codex session is not a non-archived interactive CLI or VS Code session", - ); - } - const nextCursor = page.nextCursor?.trim(); - if (!nextCursor) { - throw new CatalogParamsError( - "Codex session is not a non-archived interactive CLI or VS Code session", - ); - } - if (seenCursors.has(nextCursor)) { - throw new CatalogParamsError("Codex session eligibility could not be verified"); - } - seenCursors.add(nextCursor); - cursor = nextCursor; - } - throw new CatalogParamsError("Codex session eligibility could not be verified"); -} - function adoptionSessionKey(threadId: string): string { const digest = createHash("sha256").update(threadId).digest("hex"); return `${CODEX_SUPERVISION_SESSION_KEY_PREFIX}${digest}`; @@ -1168,14 +1133,22 @@ async function archiveLocalCodexSession(params: { export function createCodexSessionCatalogNodeInvokePolicies(): OpenClawPluginNodeInvokePolicy[] { return [ { - commands: [CODEX_APP_SERVER_THREADS_LIST_COMMAND, CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND], + commands: [ + CODEX_APP_SERVER_THREADS_LIST_COMMAND, + CODEX_APP_SERVER_THREAD_TURNS_LIST_COMMAND, + CODEX_TERMINAL_RESUME_COMMAND, + ], defaultPlatforms: ["macos", "linux", "windows"], - handle: (context) => context.invokeNode(), + handle: (context) => + context.command === CODEX_TERMINAL_RESUME_COMMAND ? { ok: true } : context.invokeNode(), }, ]; } -function toGenericCatalogHost(host: CodexSessionCatalogHost): SessionCatalogHost { +function toGenericCatalogHost( + host: CodexSessionCatalogHost, + localTerminalAvailable: boolean, +): SessionCatalogHost { const local = host.hostId === CODEX_LOCAL_SESSION_HOST_ID; return { hostId: host.hostId, @@ -1191,6 +1164,9 @@ function toGenericCatalogHost(host: CodexSessionCatalogHost): SessionCatalogHost continuableStatus && isInteractiveThreadSource(session.source); const canArchive = local && continuableStatus && isInteractiveThreadSource(session.source); + const canOpenTerminal = + isInteractiveThreadSource(session.source) && + (local ? localTerminalAvailable : host.canOpenTerminalCodex === true); return { threadId: session.threadId, ...(session.name != null ? { name: session.name } : {}), @@ -1207,6 +1183,7 @@ function toGenericCatalogHost(host: CodexSessionCatalogHost): SessionCatalogHost ...(session.openClawSessionKey ? { openClawSessionKey: session.openClawSessionKey } : {}), canContinue, canArchive, + canOpenTerminal, }; }), ...(host.nextCursor ? { nextCursor: host.nextCursor } : {}), @@ -1267,8 +1244,9 @@ function registerCodexSessionCatalog(params: { const provider: SessionCatalogProvider = { id: "codex", label: "Codex", - list: async (query) => - ( + list: async (query) => { + const localTerminalAvailable = resolveLocalCodexTerminalExecutable() !== undefined; + return ( await listCodexSessionCatalog({ bindingStore: params.bindingStore, config: params.getRuntimeConfig(), @@ -1276,7 +1254,8 @@ function registerCodexSessionCatalog(params: { control: params.control, query, }) - ).hosts.map(toGenericCatalogHost), + ).hosts.map((host) => toGenericCatalogHost(host, localTerminalAvailable)); + }, read: async (request) => { const page = await readCodexSessionTranscript({ runtime: params.api.runtime, @@ -1337,6 +1316,13 @@ function registerCodexSessionCatalog(params: { }); return { ok: true }; }, + openTerminal: (request) => + openCodexCatalogTerminal({ + api: params.api, + control: params.control, + parseCatalogPage, + ...request, + }), }; params.api.registerSessionCatalog(provider); } diff --git a/package.json b/package.json index fe53363a3da8..0425ab35a114 100644 --- a/package.json +++ b/package.json @@ -991,6 +991,10 @@ "types": "./dist/plugin-sdk/inline-image-data-url-runtime.d.ts", "default": "./dist/plugin-sdk/inline-image-data-url-runtime.js" }, + "./plugin-sdk/node-host": { + "types": "./dist/plugin-sdk/node-host.d.ts", + "default": "./dist/plugin-sdk/node-host.js" + }, "./plugin-sdk/response-limit-runtime": { "types": "./dist/plugin-sdk/response-limit-runtime.d.ts", "default": "./dist/plugin-sdk/response-limit-runtime.js" diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 0a8853f7616f..b34ecd0579a4 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -1,11 +1,4 @@ -export { - buildClawHubTrustErrorDetails, - ClawHubTrustErrorCodes, - isClawHubTrustErrorCode, - readClawHubTrustErrorDetails, - type ClawHubTrustErrorCode, - type ClawHubTrustErrorDetails, -} from "./clawhub-trust-error-details.js"; +export * from "./clawhub-trust-error-details.js"; export { validateApprovalGetResult } from "./approval-result-validators.js"; export { validateApprovalResolveResult } from "./approval-result-validators.js"; import type { ValidationError } from "./validation-errors.js"; @@ -289,6 +282,7 @@ import { NodePresenceAliveReasonSchema, NodePresenceActivityPayloadSchema, NodeInvokeParamsSchema, + NodeInvokeInputEventSchema, NodeInvokeProgressParamsSchema, NodeInvokeResultParamsSchema, NodeListParamsSchema, @@ -601,6 +595,7 @@ export const validateSystemInfoResult = lazyCompile(SystemInfoResultSchema); export const validateNodePendingAckParams = lazyCompile(NodePendingAckParamsSchema); export const validateNodeDescribeParams = lazyCompile(NodeDescribeParamsSchema); export const validateNodeInvokeParams = lazyCompile(NodeInvokeParamsSchema); +export const validateNodeInvokeInputEvent = lazyCompile(NodeInvokeInputEventSchema); export const validateNodeInvokeResultParams = lazyCompile(NodeInvokeResultParamsSchema); export const validateNodeInvokeProgressParams = lazyCompile(NodeInvokeProgressParamsSchema); export const validateNodeEventParams = lazyCompile(NodeEventParamsSchema); @@ -944,6 +939,7 @@ export { NodeSkillsUpdateParamsSchema, NodePendingAckParamsSchema, NodeInvokeParamsSchema, + NodeInvokeInputEventSchema, NodeInvokeProgressParamsSchema, NodeEventResultSchema, NodePresenceAlivePayloadSchema, @@ -1528,6 +1524,7 @@ export type { NodeSkillDescriptor, NodeSkillsUpdateParams, NodeInvokeParams, + NodeInvokeInputEvent, NodeInvokeProgressParams, NodeInvokeResultParams, NodeEventParams, diff --git a/packages/gateway-protocol/src/schema/nodes-invoke-input.test.ts b/packages/gateway-protocol/src/schema/nodes-invoke-input.test.ts new file mode 100644 index 000000000000..ac4cfe94c049 --- /dev/null +++ b/packages/gateway-protocol/src/schema/nodes-invoke-input.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { validateNodeInvokeInputEvent } from "../index.js"; + +describe("node.invoke.input protocol", () => { + it("accepts ordered bounded frames and rejects oversized or open-ended payloads", () => { + expect( + validateNodeInvokeInputEvent({ + id: "invoke-1", + nodeId: "node-1", + seq: 0, + payloadJSON: '{"kind":"data","data":"x"}', + }), + ).toBe(true); + expect( + validateNodeInvokeInputEvent({ + id: "invoke-1", + nodeId: "node-1", + seq: 0, + payloadJSON: "x".repeat(16 * 1024 + 1), + }), + ).toBe(false); + expect( + validateNodeInvokeInputEvent({ + id: "invoke-1", + nodeId: "node-1", + seq: 0, + payloadJSON: "{}", + argv: ["sh"], + }), + ).toBe(false); + }); +}); diff --git a/packages/gateway-protocol/src/schema/nodes.ts b/packages/gateway-protocol/src/schema/nodes.ts index cfbd2bc74000..bed149427180 100644 --- a/packages/gateway-protocol/src/schema/nodes.ts +++ b/packages/gateway-protocol/src/schema/nodes.ts @@ -230,6 +230,14 @@ export const NodeInvokeRequestEventSchema = closedObject({ idempotencyKey: Type.Optional(NonEmptyString), }); +/** Ordered input frame sent by the gateway to one long-lived node invoke. */ +export const NodeInvokeInputEventSchema = closedObject({ + id: NonEmptyString, + nodeId: NonEmptyString, + seq: Type.Integer({ minimum: 0 }), + payloadJSON: Type.String({ maxLength: 16 * 1024 }), +}); + // Wire types derive directly from local schema consts so public d.ts graphs never // pull in the ProtocolSchemas registry. export type NodePairListParams = Static; @@ -243,6 +251,7 @@ export type NodeDescribeParams = Static; export type NodeInvokeParams = Static; export type NodeInvokeResultParams = Static; export type NodeInvokeProgressParams = Static; +export type NodeInvokeInputEvent = Static; export type NodeEventParams = Static; export type NodeEventResult = Static; export type NodePresenceAlivePayload = Static; diff --git a/packages/gateway-protocol/src/schema/protocol-schemas-node-invoke.ts b/packages/gateway-protocol/src/schema/protocol-schemas-node-invoke.ts new file mode 100644 index 000000000000..51b3105fd9b6 --- /dev/null +++ b/packages/gateway-protocol/src/schema/protocol-schemas-node-invoke.ts @@ -0,0 +1,17 @@ +import { + NodeInvokeInputEventSchema, + NodeInvokeParamsSchema, + NodeInvokeProgressParamsSchema, + NodeInvokeRequestEventSchema, + NodeInvokeResultParamsSchema, +} from "./nodes.js"; + +// Node invoke request/input/progress/result wire schemas, grouped like the +// sibling node-presence bundle so the main registry stays within its budget. +export const NodeInvokeProtocolSchemas = { + NodeInvokeParams: NodeInvokeParamsSchema, + NodeInvokeInputEvent: NodeInvokeInputEventSchema, + NodeInvokeProgressParams: NodeInvokeProgressParamsSchema, + NodeInvokeResultParams: NodeInvokeResultParamsSchema, + NodeInvokeRequestEvent: NodeInvokeRequestEventSchema, +}; diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index f74f129b78d5..a17b50e950c0 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -310,10 +310,6 @@ import { NodePendingEnqueueParamsSchema, NodePendingEnqueueResultSchema, NodePresenceAlivePayloadSchema, - NodeInvokeParamsSchema, - NodeInvokeProgressParamsSchema, - NodeInvokeResultParamsSchema, - NodeInvokeRequestEventSchema, NodeListParamsSchema, NodePendingAckParamsSchema, NodePairApproveParamsSchema, @@ -355,6 +351,7 @@ import { PluginsUninstallParamsSchema, PluginsUninstallResultSchema, } from "./plugins.js"; +import { NodeInvokeProtocolSchemas } from "./protocol-schemas-node-invoke.js"; import { NodePresenceProtocolSchemas } from "./protocol-schemas-node-presence.js"; import { PushTestParamsSchema, PushTestResultSchema } from "./push.js"; import { @@ -587,9 +584,7 @@ export const ProtocolSchemas = { NodeSkillsUpdateParams: NodeSkillsUpdateParamsSchema, NodePendingAckParams: NodePendingAckParamsSchema, NodeDescribeParams: NodeDescribeParamsSchema, - NodeInvokeParams: NodeInvokeParamsSchema, - NodeInvokeProgressParams: NodeInvokeProgressParamsSchema, - NodeInvokeResultParams: NodeInvokeResultParamsSchema, + ...NodeInvokeProtocolSchemas, NodeEventParams: NodeEventParamsSchema, NodeEventResult: NodeEventResultSchema, NodePresenceAlivePayload: NodePresenceAlivePayloadSchema, @@ -598,7 +593,6 @@ export const ProtocolSchemas = { NodePendingDrainResult: NodePendingDrainResultSchema, NodePendingEnqueueParams: NodePendingEnqueueParamsSchema, NodePendingEnqueueResult: NodePendingEnqueueResultSchema, - NodeInvokeRequestEvent: NodeInvokeRequestEventSchema, // Push and secret-resolution payloads used by mobile/control integrations. PushTestParams: PushTestParamsSchema, diff --git a/packages/gateway-protocol/src/schema/sessions-catalog.test.ts b/packages/gateway-protocol/src/schema/sessions-catalog.test.ts index 06886b054023..112bb13a4eed 100644 --- a/packages/gateway-protocol/src/schema/sessions-catalog.test.ts +++ b/packages/gateway-protocol/src/schema/sessions-catalog.test.ts @@ -17,6 +17,7 @@ describe("SessionsCatalogListResultSchema", () => { continueSession: true, archive: false, createSession: { model: "anthropic/claude-opus-4-8" }, + openTerminal: true, }, hosts: [ { @@ -24,7 +25,16 @@ describe("SessionsCatalogListResultSchema", () => { label: "Gateway", kind: "gateway", connected: true, - sessions: [], + sessions: [ + { + threadId: "thread-1", + status: "idle", + archived: false, + canContinue: true, + canArchive: false, + canOpenTerminal: true, + }, + ], }, ], }, diff --git a/packages/gateway-protocol/src/schema/sessions-catalog.ts b/packages/gateway-protocol/src/schema/sessions-catalog.ts index bbf3283a4409..2cf4c29893c5 100644 --- a/packages/gateway-protocol/src/schema/sessions-catalog.ts +++ b/packages/gateway-protocol/src/schema/sessions-catalog.ts @@ -10,6 +10,7 @@ export const SessionCatalogCapabilitiesSchema = closedObject({ continueSession: Type.Boolean(), archive: Type.Boolean(), createSession: Type.Optional(closedObject({ model: NonEmptyString })), + openTerminal: Type.Optional(Type.Boolean()), }); export const SessionCatalogDescriptorSchema = closedObject({ @@ -34,6 +35,7 @@ export const SessionCatalogSessionSchema = closedObject({ openClawSessionKey: Type.Optional(NonEmptyString), canContinue: Type.Boolean(), canArchive: Type.Boolean(), + canOpenTerminal: Type.Optional(Type.Boolean()), }); export const SessionCatalogHostSchema = closedObject({ diff --git a/packages/gateway-protocol/src/schema/terminal.test.ts b/packages/gateway-protocol/src/schema/terminal.test.ts new file mode 100644 index 000000000000..d4c5922c8931 --- /dev/null +++ b/packages/gateway-protocol/src/schema/terminal.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { validateTerminalOpenParams } from "../index.js"; + +describe("terminal protocol", () => { + it("accepts a typed catalog reference and rejects client command fields", () => { + expect( + validateTerminalOpenParams({ + cols: 80, + rows: 24, + catalog: { catalogId: "codex", hostId: "gateway:local", threadId: "thread" }, + }), + ).toBe(true); + expect( + validateTerminalOpenParams({ + cols: 80, + rows: 24, + catalog: { + catalogId: "codex", + hostId: "gateway:local", + threadId: "thread", + argv: ["sh"], + }, + }), + ).toBe(false); + expect( + validateTerminalOpenParams({ + cols: 80, + rows: 24, + cwd: "/tmp", + }), + ).toBe(false); + }); +}); diff --git a/packages/gateway-protocol/src/schema/terminal.ts b/packages/gateway-protocol/src/schema/terminal.ts index e30459c2b9e9..0c8e05dc9a09 100644 --- a/packages/gateway-protocol/src/schema/terminal.ts +++ b/packages/gateway-protocol/src/schema/terminal.ts @@ -15,6 +15,13 @@ export const TerminalOpenParamsSchema = closedObject({ // Optional agent selector; defaults to the gateway's default agent. The // session starts in that agent's workspace and inherits its isolation. agentId: Type.Optional(NonEmptyString), + catalog: Type.Optional( + closedObject({ + catalogId: NonEmptyString, + hostId: NonEmptyString, + threadId: NonEmptyString, + }), + ), cols: TerminalDimension, rows: TerminalDimension, }); @@ -29,6 +36,7 @@ export const TerminalOpenResultSchema = closedObject({ // True when the shell runs inside the agent's sandbox and cannot escape the // workspace; false for a host shell that can navigate the whole filesystem. confined: Type.Boolean(), + title: Type.Optional(NonEmptyString), }); export type TerminalOpenResult = Static; diff --git a/scripts/deadcode-exports.baseline.mjs b/scripts/deadcode-exports.baseline.mjs index 56ec83856173..0ef01732b792 100644 --- a/scripts/deadcode-exports.baseline.mjs +++ b/scripts/deadcode-exports.baseline.mjs @@ -8,6 +8,7 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "extensions/canvas/src/host/a2ui.ts: injectCanvasRuntime", "extensions/canvas/src/host/a2ui.ts: isA2uiPath", "extensions/canvas/src/host/server.ts: CANVAS_LIVE_RELOAD_MAX_INBOUND_MESSAGE_BYTES", + "extensions/codex/src/session-catalog.ts: CODEX_TERMINAL_RESUME_COMMAND", "extensions/copilot/src/auth-bridge.ts: COPILOT_DEFAULT_AGENT_ID", "extensions/copilot/src/auth-bridge.ts: COPILOT_TOKEN_PROFILE_ERROR", "extensions/copilot/src/auth-bridge.ts: sanitizeAgentId", @@ -793,6 +794,7 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "src/flows/doctor-health-contributions.ts: resolveDoctorHealthContributions", "src/flows/doctor-health-contributions.ts: shouldSkipLegacyUpdateDoctorConfigWrite", "src/flows/health-check-adapter.ts: defineSplitHealthCheck", + "src/gateway/terminal/launch.ts: shellQuote", "src/hooks/bundled/session-memory/transcript.ts: getRecentSessionContent", "src/hooks/bundled/session-memory/transcript.ts: sanitizeSessionMemoryTranscriptText", "src/hooks/gmail-setup-utils.ts: resetGmailSetupUtilsCachesForTest", @@ -843,12 +845,15 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "src/node-host/mcp.ts: buildNodeMcpToolDescriptors", "src/node-host/mcp.ts: countConfiguredNodeHostMcpServers", "src/node-host/mcp.ts: NodeHostMcpErrorCode", + "src/node-host/node-invoke-progress.ts: resolveNodeInvokeHeartbeatInterval", "src/node-host/runner.ts: buildNodeInvokeResultParams", "src/node-host/runner.ts: handleNodeHostReconnectPaused", "src/node-host/runner.ts: resolveNodeHostGatewayCredentials", "src/node-host/runner.ts: resolveNodeHostGatewayDeviceFamily", "src/node-host/runner.ts: resolveNodeHostGatewayPlatform", "src/node-host/runner.ts: shouldExitNodeHostOnReconnectPaused", + "src/node-host/runtime.ts: dispatchNodeInvokeInput", + "src/node-host/runtime.ts: registerNodeInvokeInputHandler", "src/pairing/pairing-store.types.ts: ReadChannelAllowFromStoreForAccount", "src/pairing/pairing-store.types.ts: UpsertChannelPairingRequestForAccount", "src/plugin-state/plugin-state-store.sqlite.ts: probePluginStateStore", @@ -858,6 +863,8 @@ export const KNIP_UNUSED_EXPORT_BASELINE = [ "src/process/command-queue.ts: CommandLaneTaskTimeoutError", "src/process/command-queue.ts: resetCommandQueueStateForTest", "src/process/gateway-work-admission.ts: tryBeginGatewayIndependentRootWorkAdmission", + "src/process/terminal-pty.ts: resolveTerminalPtyInvocation", + "src/process/terminal-pty.ts: TerminalPtyHandle", "src/proxy-capture/env.ts: OPENCLAW_DEBUG_PROXY_ENABLED", "src/proxy-capture/env.ts: OPENCLAW_DEBUG_PROXY_SESSION_ID", "src/proxy-capture/proxy-server.ts: assertDebugProxyDirectUpstreamAllowed", diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index ee0204c7433d..db4d99be70b3 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -216,6 +216,7 @@ "fetch-runtime", "runtime-fetch", "inline-image-data-url-runtime", + "node-host", "response-limit-runtime", "session-binding-runtime", "session-catalog", diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index c599785e4200..253a69dadeb0 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -199,7 +199,7 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { const budgets = { publicEntrypoints: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", - 327, + 328, env, ), // ScopeTree adds six channel-policy exports, mirrored by compat, including three functions. @@ -207,12 +207,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // Its case-insensitive scope-key resolver adds one function, also mirrored by compat. publicExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", - 10665, + 10674, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( "OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", - 5371, + 5375, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/scripts/protocol-event-coverage.allowlist.json b/scripts/protocol-event-coverage.allowlist.json index 2f70050fbd42..67c7404ca089 100644 --- a/scripts/protocol-event-coverage.allowlist.json +++ b/scripts/protocol-event-coverage.allowlist.json @@ -21,7 +21,8 @@ "terminal.exit": "Embedded terminal is a web/desktop surface; iOS has no terminal client.", "update.available": "Gateway self-update notices do not apply to iOS; app updates ship via the App Store.", "session.approval": "Native approval review uses exec.approval push/nudge delivery; the session-scoped approval stream is a Control UI chat surface.", - "node.invoke.cancel": "Cancel targets streaming agent.cli.claude.run.v1 invokes; app nodes never advertise agent runs, so no cancel can address them." + "node.invoke.cancel": "Cancel targets streaming agent.cli.claude.run.v1 invokes; app nodes never advertise agent runs, so no cancel can address them.", + "node.invoke.input": "Carries terminal keystrokes/resize to a node PTY relay invoke; the relay runs on gateway/CLI node hosts and app nodes never host it, so iOS has no consumer." }, "android": { "session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.", @@ -44,6 +45,7 @@ "terminal.data": "Embedded terminal is a web/desktop surface; Android has no terminal client.", "terminal.exit": "Embedded terminal is a web/desktop surface; Android has no terminal client.", "session.approval": "Native approval review uses exec.approval push/nudge delivery; the session-scoped approval stream is a Control UI chat surface.", - "node.invoke.cancel": "Cancel targets streaming agent.cli.claude.run.v1 invokes; app nodes never advertise agent runs, so no cancel can address them." + "node.invoke.cancel": "Cancel targets streaming agent.cli.claude.run.v1 invokes; app nodes never advertise agent runs, so no cancel can address them.", + "node.invoke.input": "Carries terminal keystrokes/resize to a node PTY relay invoke; the relay runs on gateway/CLI node hosts and app nodes never host it, so Android has no consumer." } } diff --git a/src/gateway/node-registry.invoke-stream.ts b/src/gateway/node-registry.invoke-stream.ts index 3dd4c83ed5aa..ace2aa1c93fe 100644 --- a/src/gateway/node-registry.invoke-stream.ts +++ b/src/gateway/node-registry.invoke-stream.ts @@ -22,19 +22,102 @@ export type PendingInvoke = { onProgress?: (chunk: string) => void; nextProgressSeq: number; progressChunks: Map; + nextInputSeq: number; removeAbortListener?: () => void; }; +export type NodeInvokeProgressParams = { + invokeId: string; + nodeId: string; + connId: string | undefined; + seq: number; + chunk: string; +}; + +export type NodeInvokeResultParams = { + id: string; + nodeId: string; + connId: string | undefined; + ok: boolean; + payload?: unknown; + payloadJSON?: string | null; + error?: { code?: string; message?: string } | null; +}; + const MAX_PENDING_PROGRESS_CHUNKS = 128; +const MAX_INVOKE_INPUT_BYTES = 16 * 1024; export class NodeInvokeStreamController { constructor( private readonly options: { pendingInvokes: Map; sendCancel: (requestId: string, pending: PendingInvoke) => void; + isConnectionActive: (pending: PendingInvoke) => boolean; + sendInput: ( + invokeId: string, + pending: PendingInvoke, + seq: number, + payloadJSON: string, + ) => boolean; + onFailedResult: (pending: PendingInvoke) => void; + // Settles a pending invoke on transport loss. The registry's callback + // preserves MCP's structured-failure contract (resolve, not reject) so + // MCP callers can degrade instead of seeing an opaque invoke error. + disconnectPending: (pending: PendingInvoke) => void; }, ) {} + sendInput(invokeId: string, payload: unknown): void { + const pending = this.options.pendingInvokes.get(invokeId); + if (!pending) { + throw new Error("node invoke is not pending"); + } + const payloadJSON = JSON.stringify(payload); + if (payloadJSON === undefined) { + throw new Error("node invoke input is not serializable"); + } + if (Buffer.byteLength(payloadJSON, "utf8") > MAX_INVOKE_INPUT_BYTES) { + throw new Error("node invoke input exceeds 16 KiB"); + } + if (!this.options.isConnectionActive(pending)) { + throw new Error("node invoke connection is unavailable"); + } + if (!this.options.sendInput(invokeId, pending, pending.nextInputSeq, payloadJSON)) { + throw new Error("failed to send node invoke input"); + } + pending.nextInputSeq += 1; + } + + handleDisconnect(connId: string): void { + for (const [id, pending] of this.options.pendingInvokes) { + if (pending.connId !== connId) { + continue; + } + this.clearTimers(pending); + this.options.disconnectPending(pending); + this.options.pendingInvokes.delete(id); + } + } + + handleResult(params: NodeInvokeResultParams): boolean { + const pending = this.options.pendingInvokes.get(params.id); + if (!pending || pending.nodeId !== params.nodeId || pending.connId !== params.connId) { + return false; + } + this.clearTimers(pending); + this.options.pendingInvokes.delete(params.id); + if (!params.ok) { + this.options.onFailedResult(pending); + } + pending.resolve({ + ok: params.ok, + payload: params.payload, + payloadJSON: params.payloadJSON ?? null, + error: params.error ?? null, + }); + return true; + } + armPending(params: { requestId: string; pending: PendingInvoke; @@ -79,13 +162,7 @@ export class NodeInvokeStreamController { } } - handleProgress(params: { - invokeId: string; - nodeId: string; - connId: string | undefined; - seq: number; - chunk: string; - }): boolean { + handleProgress(params: NodeInvokeProgressParams): boolean { const pending = this.options.pendingInvokes.get(params.invokeId); if ( !pending || diff --git a/src/gateway/node-registry.test.ts b/src/gateway/node-registry.test.ts index 40fc58ab1bf9..344b20a40e3c 100644 --- a/src/gateway/node-registry.test.ts +++ b/src/gateway/node-registry.test.ts @@ -218,6 +218,83 @@ function authorizeSystemRun(registry: NodeRegistry, overrides: Partial { + it("routes ordered input to the pending invoke connection and rejects unknown invokes", async () => { + const registry = new NodeRegistry(); + const frames = registerNode(registry); + const controller = new AbortController(); + const invoke = registry.invoke({ + nodeId: "node-1", + command: "codex.terminal.resume.v1", + timeoutMs: 0, + signal: controller.signal, + onProgress: () => {}, + }); + const request = JSON.parse(frames[0] ?? "{}") as { payload?: { id?: string } }; + const invokeId = request.payload?.id ?? ""; + + registry.sendInvokeInput(invokeId, { kind: "data", data: "a" }); + registry.sendInvokeInput(invokeId, { kind: "resize", cols: 90, rows: 30 }); + expect(JSON.parse(frames[1] ?? "{}")).toMatchObject({ + event: "node.invoke.input", + payload: { + id: invokeId, + nodeId: "node-1", + seq: 0, + payloadJSON: JSON.stringify({ kind: "data", data: "a" }), + }, + }); + expect(JSON.parse(frames[2] ?? "{}")).toMatchObject({ + event: "node.invoke.input", + payload: { id: invokeId, nodeId: "node-1", seq: 1 }, + }); + expect(() => registry.sendInvokeInput("missing", { kind: "data", data: "x" })).toThrow( + "node invoke is not pending", + ); + + controller.abort(); + await expect(invoke).resolves.toMatchObject({ ok: false, error: { code: "ABORTED" } }); + }); + + it("rejects node invoke input above 16 KiB", async () => { + const registry = new NodeRegistry(); + const frames = registerNode(registry); + const controller = new AbortController(); + const invoke = registry.invoke({ + nodeId: "node-1", + command: "codex.terminal.resume.v1", + timeoutMs: 0, + signal: controller.signal, + }); + const request = JSON.parse(frames[0] ?? "{}") as { payload?: { id?: string } }; + expect(() => + registry.sendInvokeInput(request.payload?.id ?? "", { + kind: "data", + data: "x".repeat(17 * 1024), + }), + ).toThrow("exceeds 16 KiB"); + controller.abort(); + await invoke; + }); + + it("rejects node invoke input that cannot be serialized", async () => { + const registry = new NodeRegistry(); + const frames = registerNode(registry); + const controller = new AbortController(); + const invoke = registry.invoke({ + nodeId: "node-1", + command: "codex.terminal.resume.v1", + timeoutMs: 0, + signal: controller.signal, + }); + const request = JSON.parse(frames[0] ?? "{}") as { payload?: { id?: string } }; + + expect(() => registry.sendInvokeInput(request.payload?.id ?? "", undefined)).toThrow( + "node invoke input is not serializable", + ); + controller.abort(); + await invoke; + }); + it("ranks connected nodes by gateway-derived input activity", () => { const registry = createTestNodeRegistry(); registry.register( diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts index 1e029ca309dd..4f6dd2393d1a 100644 --- a/src/gateway/node-registry.ts +++ b/src/gateway/node-registry.ts @@ -27,6 +27,8 @@ import { } from "./node-plugin-tool-snapshot.js"; import { NodeInvokeStreamController, + type NodeInvokeProgressParams, + type NodeInvokeResultParams, type PendingInvoke, type PendingSystemRunEvent, } from "./node-registry.invoke-stream.js"; @@ -210,6 +212,40 @@ export class NodeRegistry { nodeId: pending.nodeId, }); }, + isConnectionActive: (pending) => this.nodesById.get(pending.nodeId)?.connId === pending.connId, + sendInput: (invokeId, pending, seq, payloadJSON) => { + const node = this.nodesById.get(pending.nodeId); + return node + ? this.sendEventToSession(node, "node.invoke.input", { + id: invokeId, + nodeId: pending.nodeId, + seq, + payloadJSON, + }) + : false; + }, + onFailedResult: (pending) => { + if (pending.systemRunEvent) { + this.forgetAuthorizedSystemRunEvent({ + nodeId: pending.nodeId, + connId: pending.connId, + ...pending.systemRunEvent, + }); + } + }, + disconnectPending: (pending) => { + if (pending.command === NODE_MCP_TOOLS_CALL_COMMAND) { + pending.resolve({ + ok: false, + error: { + code: "MCP_SERVER_UNAVAILABLE", + message: "node host disconnected during MCP tool call", + }, + }); + } else { + pending.reject(new Error(`node disconnected (${pending.command})`)); + } + }, }); private authorizedSystemRunEvents = new Map(); @@ -379,26 +415,7 @@ export class NodeRegistry { this.publishActiveNodeContext(); } } - for (const [id, pending] of this.pendingInvokes.entries()) { - if (pending.connId !== connId) { - continue; - } - this.invokeStreams.clearTimers(pending); - if (pending.command === NODE_MCP_TOOLS_CALL_COMMAND) { - // Preserve MCP's structured failure contract when transport loss wins - // the race; callers can degrade instead of seeing an opaque invoke error. - pending.resolve({ - ok: false, - error: { - code: "MCP_SERVER_UNAVAILABLE", - message: "node host disconnected during MCP tool call", - }, - }); - } else { - pending.reject(new Error(`node disconnected (${pending.command})`)); - } - this.pendingInvokes.delete(id); - } + this.invokeStreams.handleDisconnect(connId); for (const [key, event] of this.authorizedSystemRunEvents) { if (event.connId === connId) { this.authorizedSystemRunEvents.delete(key); @@ -672,6 +689,8 @@ export class NodeRegistry { onProgress?: (chunk: string) => void; signal?: AbortSignal; idempotencyKey?: string; + /** Receives the id synchronously after send; the terminal relay depends on this timing. */ + onInvokeId?: (invokeId: string) => void; }): Promise { if (params.signal?.aborted) { return { ok: false, error: { code: "ABORTED", message: "node invoke cancelled" } }; @@ -705,25 +724,11 @@ export class NodeRegistry { timeoutMs, idempotencyKey: params.idempotencyKey, }; - const ok = this.sendEventToSession(node, "node.invoke.request", payload); - if (!ok) { - return { - ok: false, - error: { code: "UNAVAILABLE", message: "failed to send invoke to node" }, - }; - } const systemRunEvent = resolvePendingSystemRunEvent({ command: params.command, params: invokeParams, }); - if (systemRunEvent) { - this.rememberAuthorizedSystemRunEvent({ - nodeId: params.nodeId, - connId: node.connId, - ...systemRunEvent, - }); - } - return await new Promise((resolve, reject) => { + const result = new Promise((resolve, reject) => { const pending: PendingInvoke = { nodeId: params.nodeId, connId: node.connId, @@ -733,6 +738,7 @@ export class NodeRegistry { reject, nextProgressSeq: 0, progressChunks: new Map(), + nextInputSeq: 0, ...(params.onProgress ? { onProgress: params.onProgress } : {}), }; const idleTimeoutMs = resolveTimerTimeoutMs(params.idleTimeoutMs, 0, 0); @@ -744,15 +750,39 @@ export class NodeRegistry { ...(params.signal ? { signal: params.signal } : {}), }); }); + if (!this.pendingInvokes.has(requestId)) { + return await result; + } + const ok = this.sendEventToSession(node, "node.invoke.request", payload); + if (!ok) { + const pending = this.pendingInvokes.get(requestId); + if (pending) { + this.invokeStreams.clearTimers(pending); + this.pendingInvokes.delete(requestId); + pending.resolve({ + ok: false, + error: { code: "UNAVAILABLE", message: "failed to send invoke to node" }, + }); + } + return await result; + } + if (systemRunEvent) { + this.rememberAuthorizedSystemRunEvent({ + nodeId: params.nodeId, + connId: node.connId, + ...systemRunEvent, + }); + } + params.onInvokeId?.(requestId); + return await result; } - handleInvokeProgress(params: { - invokeId: string; - nodeId: string; - connId: string | undefined; - seq: number; - chunk: string; - }): boolean { + /** Send one ordered input frame to a pending streaming invoke. */ + sendInvokeInput(invokeId: string, payload: unknown): void { + this.invokeStreams.sendInput(invokeId, payload); + } + + handleInvokeProgress(params: NodeInvokeProgressParams): boolean { return this.invokeStreams.handleProgress(params); } @@ -905,38 +935,8 @@ export class NodeRegistry { return `${params.nodeId}\0${params.connId}\0${params.sessionKey ?? ""}\0${params.runId}`; } - handleInvokeResult(params: { - id: string; - nodeId: string; - connId: string | undefined; - ok: boolean; - payload?: unknown; - payloadJSON?: string | null; - error?: { code?: string; message?: string } | null; - }): boolean { - const pending = this.pendingInvokes.get(params.id); - if (!pending) { - return false; - } - if (pending.nodeId !== params.nodeId || pending.connId !== params.connId) { - return false; - } - this.invokeStreams.clearTimers(pending); - this.pendingInvokes.delete(params.id); - if (!params.ok && pending.systemRunEvent) { - this.forgetAuthorizedSystemRunEvent({ - nodeId: pending.nodeId, - connId: pending.connId, - ...pending.systemRunEvent, - }); - } - pending.resolve({ - ok: params.ok, - payload: params.payload, - payloadJSON: params.payloadJSON ?? null, - error: params.error ?? null, - }); - return true; + handleInvokeResult(params: NodeInvokeResultParams): boolean { + return this.invokeStreams.handleResult(params); } sendEvent(nodeId: string, event: string, payload?: unknown): boolean { diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 1fd5f2e27b00..61c1e307f233 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -59,6 +59,7 @@ export const GATEWAY_EVENTS = [ "node.pair.resolved", "node.presence", "node.invoke.cancel", + "node.invoke.input", "node.invoke.request", "device.pair.requested", "device.pair.resolved", diff --git a/src/gateway/server-methods/session-catalog.test.ts b/src/gateway/server-methods/session-catalog.test.ts index 611c7f0e2c87..f0287ac54ac8 100644 --- a/src/gateway/server-methods/session-catalog.test.ts +++ b/src/gateway/server-methods/session-catalog.test.ts @@ -79,6 +79,24 @@ describe("session catalog Gateway methods", () => { }); }); + it("advertises terminal opening only for providers that implement it", async () => { + activeRegistry.sessionCatalogs = [ + { + provider: provider("codex", { + openTerminal: async () => ({ kind: "local", argv: ["codex", "resume", "thread"] }), + }), + }, + { provider: provider("readonly") }, + ]; + const respond = await call("sessions.catalog.list", {}); + expect(respond).toHaveBeenCalledWith(true, { + catalogs: [ + expect.objectContaining({ capabilities: expect.objectContaining({ openTerminal: true }) }), + expect.objectContaining({ capabilities: { continueSession: false, archive: false } }), + ], + }); + }); + it("refreshes a provider's core new-session target when listing", async () => { let createSession: { model: string; agentRuntime: string } | undefined = { model: "anthropic/claude-opus-4-8", diff --git a/src/gateway/server-methods/session-catalog.ts b/src/gateway/server-methods/session-catalog.ts index 334aaa495164..98d913ae0c7a 100644 --- a/src/gateway/server-methods/session-catalog.ts +++ b/src/gateway/server-methods/session-catalog.ts @@ -37,6 +37,12 @@ function providers(): SessionCatalogProvider[] { return registrations().map((entry) => entry.provider); } +export function resolveSessionCatalogProvider( + catalogId: string, +): SessionCatalogProvider | undefined { + return providers().find((candidate) => candidate.id === catalogId); +} + function registrations() { return (getPluginRegistryState()?.activeRegistry?.sessionCatalogs ?? []).toSorted((left, right) => left.provider.id.localeCompare(right.provider.id), @@ -90,7 +96,7 @@ function providerOrRespond( catalogId: string, respond: RespondFn, ): SessionCatalogProvider | undefined { - const provider = providers().find((candidate) => candidate.id === catalogId); + const provider = resolveSessionCatalogProvider(catalogId); if (!provider) { respond( false, @@ -125,6 +131,7 @@ function catalogResult( capabilities: { continueSession: Boolean(provider.continueSession), archive: Boolean(provider.archive), + ...(provider.openTerminal ? { openTerminal: true } : {}), ...(createSession ? { createSession } : {}), }, hosts, diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 81c63f0624fb..affa6acce04e 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -129,6 +129,7 @@ export type GatewayRequestContext = { nodeUnsubscribe: (nodeId: string, sessionKey: string) => void; nodeUnsubscribeAll: (nodeId: string) => void; hasConnectedTalkNode: () => boolean; + isConnectionActive?: (connId: string) => boolean; hasExecApprovalClients?: (excludeConnId?: string) => boolean; getApprovalClientConnIds?: (params?: { excludeConnId?: string; diff --git a/src/gateway/server-methods/terminal-open-plan.ts b/src/gateway/server-methods/terminal-open-plan.ts new file mode 100644 index 000000000000..c175181be107 --- /dev/null +++ b/src/gateway/server-methods/terminal-open-plan.ts @@ -0,0 +1,62 @@ +// Catalog terminal admission and spawn planning live here so the RPC handler +// can repeat the security checks without duplicating policy logic. +import type { SessionCatalogTerminalPlan } from "../../plugins/session-catalog.js"; +import { isNodeCommandAllowed, resolveNodeCommandAllowlist } from "../node-command-policy.js"; +import { + resolveTerminalSpawnPlan, + type TerminalLaunchPlan, + type TerminalSpawnPlan, +} from "../terminal/launch.js"; +import type { GatewayRequestHandlerOptions } from "./types.js"; + +type NodeCatalogTerminalPlan = Extract; + +export function authorizeCatalogTerminalNode( + context: GatewayRequestHandlerOptions["context"], + plan: NodeCatalogTerminalPlan, +): + | { ok: true; node: NonNullable> } + | { + ok: false; + message: string; + } { + const node = context.nodeRegistry.get(plan.nodeId); + if (!node) { + return { ok: false, message: "catalog terminal node is not connected" }; + } + if (!node.commands.includes(plan.command)) { + return { ok: false, message: "catalog terminal command is not available" }; + } + const allowlist = resolveNodeCommandAllowlist(context.getRuntimeConfig(), { + ...node, + approvedCommands: node.commands, + }); + const allowed = isNodeCommandAllowed({ + command: plan.command, + declaredCommands: node.commands, + allowlist, + }); + return allowed.ok ? { ok: true, node } : { ok: false, message: allowed.reason }; +} + +export function resolveTerminalOpenSpawnPlan( + launchPlan: TerminalLaunchPlan, + catalogPlan?: SessionCatalogTerminalPlan, +): TerminalSpawnPlan { + if (!catalogPlan) { + return resolveTerminalSpawnPlan(launchPlan); + } + if (catalogPlan.kind === "local") { + return resolveTerminalSpawnPlan({ + ...launchPlan, + initialCommand: catalogPlan.argv, + cwdOverride: catalogPlan.cwd, + }); + } + return { + agentId: launchPlan.agentId, + cwd: catalogPlan.cwd ?? launchPlan.cwd, + shell: catalogPlan.title ?? catalogPlan.command, + args: [], + }; +} diff --git a/src/gateway/server-methods/terminal.test.ts b/src/gateway/server-methods/terminal.test.ts index 67267cc464df..76e5dba7256e 100644 --- a/src/gateway/server-methods/terminal.test.ts +++ b/src/gateway/server-methods/terminal.test.ts @@ -1,16 +1,53 @@ import { expectDefined } from "@openclaw/normalization-core"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; +import type { SessionCatalogProvider } from "../../plugins/session-catalog.js"; import { createTerminalLaunchPolicy } from "../terminal/launch.js"; import { terminalHandlers } from "./terminal.js"; +const policyMocks = vi.hoisted(() => ({ + resolveNodeCommandAllowlist: vi.fn(() => new Set()), + isNodeCommandAllowed: vi.fn<() => { ok: true } | { ok: false; reason: string }>(() => ({ + ok: true, + })), + applyPluginNodeInvokePolicy: vi.fn<() => Promise<{ ok: false; message: string } | null>>( + async () => null, + ), +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +vi.mock("../node-command-policy.js", () => ({ + resolveNodeCommandAllowlist: policyMocks.resolveNodeCommandAllowlist, + isNodeCommandAllowed: policyMocks.isNodeCommandAllowed, +})); + +vi.mock("../node-invoke-plugin-policy.js", () => ({ + applyPluginNodeInvokePolicy: policyMocks.applyPluginNodeInvokePolicy, +})); + function makeOpts( params: unknown, terminalConfig: { enabled?: boolean } | undefined, terminalPolicyConfig?: OpenClawConfig, + nodeRegistry: { get: (nodeId: string) => unknown } = { get: () => undefined }, ) { const sessions = { - open: vi.fn(), + open: vi.fn(async () => ({ + ok: true as const, + sessionId: "terminal-1", + agentId: "main", + shell: "/bin/zsh", + cwd: "/work", + })), write: vi.fn(() => true), resize: vi.fn(() => true), close: vi.fn(() => true), @@ -22,11 +59,16 @@ function makeOpts( policy.prepareConfig(terminalPolicyConfig, { restartPending: true }); } const respond = vi.fn(); + const isConnectionActive = vi.fn(() => true); + const isTerminalEnabled = vi.fn(() => policy.isEnabled()); + const resolveTerminalLaunchPolicy = vi.fn((agentId?: string) => policy.resolve(agentId)); const context = { getRuntimeConfig: () => runtimeConfig, - resolveTerminalLaunchPolicy: (agentId?: string) => policy.resolve(agentId), - isTerminalEnabled: () => policy.isEnabled(), + resolveTerminalLaunchPolicy, + isTerminalEnabled, terminalSessions: sessions, + nodeRegistry, + isConnectionActive, logGateway: { info: vi.fn() }, } as unknown as Parameters<(typeof terminalHandlers)["terminal.input"]>[0]["context"]; const opts = { @@ -35,10 +77,377 @@ function makeOpts( context, client: { connId: "conn-1", connect: {} }, } as unknown as Parameters<(typeof terminalHandlers)["terminal.input"]>[0]; - return { opts, sessions, respond }; + return { + opts, + sessions, + respond, + isConnectionActive, + isTerminalEnabled, + resolveTerminalLaunchPolicy, + }; } +function installCatalog(provider: SessionCatalogProvider) { + const registry = createEmptyPluginRegistry(); + registry.sessionCatalogs.push({ pluginId: "test", provider, source: "test" }); + setActivePluginRegistry(registry); +} + +afterEach(() => { + resetPluginRuntimeStateForTest(); + policyMocks.resolveNodeCommandAllowlist.mockReset(); + policyMocks.isNodeCommandAllowed.mockReset().mockReturnValue({ ok: true }); + policyMocks.applyPluginNodeInvokePolicy.mockReset().mockResolvedValue(null); +}); + describe("terminal gateway policy", () => { + it("rejects catalog opens for missing providers", async () => { + const { opts, sessions, respond } = makeOpts( + { + cols: 80, + rows: 24, + catalog: { catalogId: "missing", hostId: "gateway:local", threadId: "thread" }, + }, + { enabled: true }, + ); + await expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts); + expect(sessions.open).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith(false, undefined, expect.any(Object)); + }); + + it("opens a provider-built local resume plan and returns its title", async () => { + const openTerminal = vi.fn(async () => ({ + kind: "local" as const, + argv: ["codex", "resume", "thread"], + title: "codex resume thread", + })); + installCatalog({ + id: "codex", + label: "Codex", + list: async () => [], + read: async (request) => ({ + hostId: request.hostId, + threadId: request.threadId, + items: [], + }), + openTerminal, + }); + const { opts, sessions, respond } = makeOpts( + { + cols: 80, + rows: 24, + catalog: { catalogId: "codex", hostId: "gateway:local", threadId: "thread" }, + }, + { enabled: true }, + ); + await expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts); + + expect(openTerminal).toHaveBeenCalledWith({ hostId: "gateway:local", threadId: "thread" }); + expect(sessions.open).toHaveBeenCalledWith( + expect.objectContaining({ + shell: expect.any(String), + args: ["-il", "-c", "'codex' 'resume' 'thread'"], + }), + ); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ sessionId: "terminal-1", title: "codex resume thread" }), + ); + }); + + it("does not create a terminal after the owning connection closes during catalog lookup", async () => { + const plan = deferred<{ kind: "local"; argv: string[] }>(); + const openTerminal = vi.fn(() => plan.promise); + installCatalog({ + id: "codex", + label: "Codex", + list: async () => [], + read: async (request) => ({ ...request, items: [] }), + openTerminal, + }); + const { opts, sessions, respond, isConnectionActive } = makeOpts( + { + cols: 80, + rows: 24, + catalog: { catalogId: "codex", hostId: "gateway:local", threadId: "thread" }, + }, + { enabled: true }, + ); + + const opening = expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts); + await vi.waitFor(() => expect(openTerminal).toHaveBeenCalledOnce()); + isConnectionActive.mockReturnValue(false); + plan.resolve({ kind: "local", argv: ["codex", "resume", "thread"] }); + await opening; + + expect(sessions.open).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: "terminal connection closed" }), + ); + }); + + it("does not create a terminal when disabled during catalog lookup", async () => { + const plan = deferred<{ kind: "local"; argv: string[] }>(); + const openTerminal = vi.fn(() => plan.promise); + installCatalog({ + id: "codex", + label: "Codex", + list: async () => [], + read: async (request) => ({ ...request, items: [] }), + openTerminal, + }); + const { opts, sessions, respond, isTerminalEnabled } = makeOpts( + { + cols: 80, + rows: 24, + catalog: { catalogId: "codex", hostId: "gateway:local", threadId: "thread" }, + }, + { enabled: true }, + ); + + const opening = expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts); + await vi.waitFor(() => expect(openTerminal).toHaveBeenCalledOnce()); + isTerminalEnabled.mockReturnValue(false); + plan.resolve({ kind: "local", argv: ["codex", "resume", "thread"] }); + await opening; + + expect(sessions.open).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: "terminal is disabled" }), + ); + }); + + it("uses the refreshed launch plan after catalog lookup", async () => { + const plan = deferred<{ kind: "local"; argv: string[] }>(); + const openTerminal = vi.fn(() => plan.promise); + installCatalog({ + id: "codex", + label: "Codex", + list: async () => [], + read: async (request) => ({ ...request, items: [] }), + openTerminal, + }); + const { opts, sessions, resolveTerminalLaunchPolicy } = makeOpts( + { + cols: 80, + rows: 24, + catalog: { catalogId: "codex", hostId: "gateway:local", threadId: "thread" }, + }, + { enabled: true }, + ); + + const opening = expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts); + await vi.waitFor(() => expect(openTerminal).toHaveBeenCalledOnce()); + resolveTerminalLaunchPolicy.mockReturnValue({ + ok: true, + plan: { agentId: "main", cwd: process.cwd(), shell: "/bin/refreshed", args: [] }, + }); + plan.resolve({ kind: "local", argv: ["codex", "resume", "thread"] }); + await opening; + + expect(sessions.open).toHaveBeenCalledWith( + expect.objectContaining({ cwd: process.cwd(), shell: "/bin/refreshed" }), + ); + }); + + it("rejects a node plan when its owner node is disconnected", async () => { + installCatalog({ + id: "claude", + label: "Claude", + list: async () => [], + read: async (request) => ({ + hostId: request.hostId, + threadId: request.threadId, + items: [], + }), + openTerminal: async () => ({ + kind: "node", + nodeId: "node-1", + command: "anthropic.claude.terminal.resume.v1", + paramsJSON: JSON.stringify({ threadId: "thread" }), + }), + }); + const { opts, sessions, respond } = makeOpts( + { + cols: 80, + rows: 24, + catalog: { catalogId: "claude", hostId: "node:node-1", threadId: "thread" }, + }, + { enabled: true }, + ); + await expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts); + expect(sessions.open).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith(false, undefined, expect.any(Object)); + }); + + it("rejects a node plan denied by the node command allowlist", async () => { + const command = "anthropic.claude.terminal.resume.v1"; + installCatalog({ + id: "claude", + label: "Claude", + list: async () => [], + read: async (request) => ({ ...request, items: [] }), + openTerminal: async () => ({ + kind: "node", + nodeId: "node-1", + command, + paramsJSON: JSON.stringify({ threadId: "thread" }), + }), + }); + policyMocks.isNodeCommandAllowed.mockReturnValue({ + ok: false, + reason: "command not allowlisted", + }); + const { opts, sessions, respond } = makeOpts( + { + cols: 80, + rows: 24, + catalog: { catalogId: "claude", hostId: "node:node-1", threadId: "thread" }, + }, + { enabled: true }, + undefined, + { + get: () => ({ nodeId: "node-1", connId: "conn-node", commands: [command] }), + }, + ); + + await expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts); + + expect(sessions.open).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: "command not allowlisted" }), + ); + }); + + it("opens a normally approved node relay after generic invoke policy", async () => { + const command = "anthropic.claude.terminal.resume.v1"; + installCatalog({ + id: "claude", + label: "Claude", + list: async () => [], + read: async (request) => ({ ...request, items: [] }), + openTerminal: async () => ({ + kind: "node", + nodeId: "node-1", + command, + paramsJSON: JSON.stringify({ threadId: "thread" }), + }), + }); + const node = { nodeId: "node-1", connId: "conn-node", commands: [command] }; + const { opts, sessions } = makeOpts( + { + cols: 100, + rows: 30, + catalog: { catalogId: "claude", hostId: "node:node-1", threadId: "thread" }, + }, + { enabled: true }, + undefined, + { get: () => node }, + ); + + await expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts); + + expect(policyMocks.resolveNodeCommandAllowlist).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ nodeId: "node-1", approvedCommands: [command] }), + ); + expect(policyMocks.applyPluginNodeInvokePolicy).toHaveBeenCalledWith( + expect.objectContaining({ + nodeSession: node, + command, + params: { threadId: "thread", cols: 100, rows: 30 }, + }), + ); + expect(sessions.open).toHaveBeenCalledOnce(); + }); + + it("rejects a replacement node connection that lacks the terminal command", async () => { + const command = "anthropic.claude.terminal.resume.v1"; + const policy = deferred(); + policyMocks.applyPluginNodeInvokePolicy.mockImplementationOnce(() => policy.promise); + installCatalog({ + id: "claude", + label: "Claude", + list: async () => [], + read: async (request) => ({ ...request, items: [] }), + openTerminal: async () => ({ + kind: "node", + nodeId: "node-1", + command, + paramsJSON: JSON.stringify({ threadId: "thread" }), + }), + }); + let node = { nodeId: "node-1", connId: "conn-old", commands: [command] }; + const { opts, sessions, respond } = makeOpts( + { + cols: 80, + rows: 24, + catalog: { catalogId: "claude", hostId: "node:node-1", threadId: "thread" }, + }, + { enabled: true }, + undefined, + { get: () => node }, + ); + + const opening = expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts); + await vi.waitFor(() => expect(policyMocks.applyPluginNodeInvokePolicy).toHaveBeenCalledOnce()); + node = { nodeId: "node-1", connId: "conn-new", commands: [] }; + policy.resolve(null); + await opening; + + expect(sessions.open).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: "catalog terminal command is not available" }), + ); + }); + + it("reports plugin invoke policy denial as unavailable", async () => { + const command = "codex.terminal.resume.v1"; + installCatalog({ + id: "codex", + label: "Codex", + list: async () => [], + read: async (request) => ({ ...request, items: [] }), + openTerminal: async () => ({ + kind: "node", + nodeId: "node-1", + command, + paramsJSON: JSON.stringify({ threadId: "thread" }), + }), + }); + policyMocks.applyPluginNodeInvokePolicy.mockResolvedValue({ + ok: false, + message: "terminal resume denied", + }); + const { opts, sessions, respond } = makeOpts( + { + cols: 80, + rows: 24, + catalog: { catalogId: "codex", hostId: "node:node-1", threadId: "thread" }, + }, + { enabled: true }, + undefined, + { get: () => ({ nodeId: "node-1", connId: "conn-node", commands: [command] }) }, + ); + + await expectDefined(terminalHandlers["terminal.open"], "terminal.open")(opts); + + expect(sessions.open).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ message: "terminal resume denied" }), + ); + }); + it("rejects reopening after an accepted disable while restart is pending", async () => { const { opts, sessions, respond } = makeOpts( { cols: 80, rows: 24 }, diff --git a/src/gateway/server-methods/terminal.ts b/src/gateway/server-methods/terminal.ts index 7782a9c9d88f..4a1a7a9aca92 100644 --- a/src/gateway/server-methods/terminal.ts +++ b/src/gateway/server-methods/terminal.ts @@ -6,6 +6,7 @@ import { ErrorCodes, errorShape, formatValidationErrors, + type TerminalOpenParams, validateTerminalAttachParams, validateTerminalCloseParams, validateTerminalInputParams, @@ -13,8 +14,16 @@ import { validateTerminalResizeParams, validateTerminalTextParams, } from "../../../packages/gateway-protocol/src/index.js"; +import type { SessionCatalogTerminalPlan } from "../../plugins/session-catalog.js"; +import { applyPluginNodeInvokePolicy } from "../node-invoke-plugin-policy.js"; import { renderTerminalBufferText } from "../terminal/buffer-text.js"; -import { buildTerminalEnv } from "../terminal/launch.js"; +import { buildTerminalEnv, type TerminalLaunchResolution } from "../terminal/launch.js"; +import { createNodeRelayBackend } from "../terminal/node-relay.js"; +import { resolveSessionCatalogProvider } from "./session-catalog.js"; +import { + authorizeCatalogTerminalNode, + resolveTerminalOpenSpawnPlan, +} from "./terminal-open-plan.js"; import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js"; function invalid(respond: GatewayRequestHandlerOptions["respond"], detail: string): void { @@ -34,6 +43,33 @@ function terminalEnabled(context: GatewayRequestHandlerOptions["context"]): bool return context.isTerminalEnabled(); } +function respondLaunchBlocked( + respond: GatewayRequestHandlerOptions["respond"], + block: Extract["block"], +): void { + if (block.kind === "disabled") { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is disabled")); + return; + } + if (block.kind === "unknown-agent") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent "${block.agentId}"`), + ); + return; + } + // Fail closed: a sandboxed agent must never receive a host shell. + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `terminal unavailable: agent "${block.agentId}" runs in a sandbox (mode "${block.mode}"); in-sandbox terminals are not supported yet`, + ), + ); +} + /** Handlers for the operator terminal method family. */ export const terminalHandlers: GatewayRequestHandlers = { "terminal.open": async (opts) => { @@ -54,42 +90,126 @@ export const terminalHandlers: GatewayRequestHandlers = { respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is not available")); return; } - const p = params as { agentId?: string; cols: number; rows: number }; + const p = params as TerminalOpenParams; const launch = context.resolveTerminalLaunchPolicy(p.agentId); if (!launch.ok) { - if (launch.block.kind === "disabled") { - respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is disabled")); - return; - } - if (launch.block.kind === "unknown-agent") { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `unknown agent "${launch.block.agentId}"`), - ); - return; - } - // Fail closed: a sandboxed agent must never receive a host shell. - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `terminal unavailable: agent "${launch.block.agentId}" runs in a sandbox (mode "${launch.block.mode}"); in-sandbox terminals are not supported yet`, - ), - ); + respondLaunchBlocked(respond, launch.block); return; } + let catalogPlan: SessionCatalogTerminalPlan | undefined; + let title: string | undefined; + let createBackend: (() => ReturnType) | undefined; + if (p.catalog) { + const provider = resolveSessionCatalogProvider(p.catalog.catalogId); + if (!provider) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown session catalog: ${p.catalog.catalogId}`), + ); + return; + } + if (!provider.openTerminal) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "session catalog cannot open terminals"), + ); + return; + } + try { + catalogPlan = await provider.openTerminal({ + hostId: p.catalog.hostId, + threadId: p.catalog.threadId, + }); + } catch (error) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + error instanceof Error ? error.message : "catalog terminal open failed", + ), + ); + return; + } + title = catalogPlan.title; + if (catalogPlan.kind === "local") { + if (catalogPlan.argv.length === 0) { + invalid(respond, "catalog terminal plan has no command"); + return; + } + } else { + const nodeCatalogPlan = catalogPlan; + const access = authorizeCatalogTerminalNode(context, nodeCatalogPlan); + if (!access.ok) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, access.message)); + return; + } + let nodeParams: Record; + try { + const parsed = JSON.parse(catalogPlan.paramsJSON) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("invalid params"); + } + nodeParams = { ...(parsed as Record), cols: p.cols, rows: p.rows }; + } catch { + invalid(respond, "catalog terminal plan has invalid params"); + return; + } + const policyResult = await applyPluginNodeInvokePolicy({ + context, + client: opts.client, + nodeSession: access.node, + command: nodeCatalogPlan.command, + params: nodeParams, + }); + if (policyResult && !policyResult.ok) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, policyResult.message)); + return; + } + createBackend = async () => + await createNodeRelayBackend({ + registry: context.nodeRegistry, + nodeId: nodeCatalogPlan.nodeId, + command: nodeCatalogPlan.command, + params: nodeParams, + }); + } + } + + if (context.isConnectionActive?.(connId) === false) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal connection closed")); + return; + } + if (!terminalEnabled(context)) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "terminal is disabled")); + return; + } + const refreshedLaunch = context.resolveTerminalLaunchPolicy(p.agentId); + if (!refreshedLaunch.ok) { + respondLaunchBlocked(respond, refreshedLaunch.block); + return; + } + if (catalogPlan?.kind === "node") { + const access = authorizeCatalogTerminalNode(context, catalogPlan); + if (!access.ok) { + respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, access.message)); + return; + } + } + const spawnPlan = resolveTerminalOpenSpawnPlan(refreshedLaunch.plan, catalogPlan); const outcome = await manager.open({ connId, - agentId: launch.plan.agentId, - cwd: launch.plan.cwd, - shell: launch.plan.shell, - args: launch.plan.args, + agentId: spawnPlan.agentId, + cwd: spawnPlan.cwd, + shell: spawnPlan.shell, + args: spawnPlan.args, cols: p.cols, rows: p.rows, env: buildTerminalEnv(process.env), + ...(createBackend ? { createBackend } : {}), }); if (!outcome.ok) { const code = outcome.code === "limit" ? ErrorCodes.INVALID_REQUEST : ErrorCodes.UNAVAILABLE; @@ -105,6 +225,7 @@ export const terminalHandlers: GatewayRequestHandlers = { shell: outcome.shell, cwd: outcome.cwd, confined: false, + ...(title ? { title } : {}), }); }, diff --git a/src/gateway/server-request-context.ts b/src/gateway/server-request-context.ts index 522b8f33f059..0eba4eb89c80 100644 --- a/src/gateway/server-request-context.ts +++ b/src/gateway/server-request-context.ts @@ -130,6 +130,8 @@ export function createGatewayRequestContext( nodeUnsubscribe: params.nodeUnsubscribe, nodeUnsubscribeAll: params.nodeUnsubscribeAll, hasConnectedTalkNode: params.hasConnectedTalkNode, + isConnectionActive: (connId) => + [...params.clients].some((client) => client.connId === connId && !client.invalidated), hasExecApprovalClients: (excludeConnId?: string) => { for (const gatewayClient of params.clients) { if (excludeConnId && gatewayClient.connId === excludeConnId) { diff --git a/src/gateway/terminal/backend.ts b/src/gateway/terminal/backend.ts new file mode 100644 index 000000000000..eadbff549707 --- /dev/null +++ b/src/gateway/terminal/backend.ts @@ -0,0 +1,31 @@ +import { spawnTerminalPty } from "../../process/terminal-pty.js"; + +export type TerminalBackendExit = { + exitCode?: number; + signal?: number; + error?: string; +}; + +export interface TerminalBackend { + write(data: string): void; + resize(cols: number, rows: number): void; + kill(): void; + onData(callback: (data: string) => void): void; + onExit(callback: (exit: TerminalBackendExit) => void): void; +} + +export type LocalTerminalBackendSpawner = typeof spawnTerminalPty; + +export async function createLocalTerminalBackend( + params: Parameters[0], + spawn: LocalTerminalBackendSpawner = spawnTerminalPty, +): Promise { + const pty = await spawn(params); + return { + write: (data) => pty.write(data), + resize: (cols, rows) => pty.resize(cols, rows), + kill: () => pty.kill(), + onData: (callback) => pty.onData(callback), + onExit: (callback) => pty.onExit(callback), + }; +} diff --git a/src/gateway/terminal/launch.test.ts b/src/gateway/terminal/launch.test.ts index 868251713fce..b8ab54fb696f 100644 --- a/src/gateway/terminal/launch.test.ts +++ b/src/gateway/terminal/launch.test.ts @@ -1,7 +1,12 @@ import { afterEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { buildTerminalEnv, createTerminalLaunchPolicy } from "./launch.js"; +import { + buildTerminalEnv, + createTerminalLaunchPolicy, + resolveTerminalSpawnPlan, + shellQuote, +} from "./launch.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); @@ -352,3 +357,56 @@ describe("buildTerminalEnv", () => { expect(env.TERM).toBe("screen-256color"); }); }); + +describe("resolveTerminalSpawnPlan", () => { + it("quotes every command argument for a login shell", () => { + expect(shellQuote("plain")).toBe("'plain'"); + expect(shellQuote("a b;$HOME")).toBe("'a b;$HOME'"); + expect(shellQuote("it's")).toBe("'it'\"'\"'s'"); + + const plan = resolveTerminalSpawnPlan({ + agentId: "main", + cwd: "/work", + shell: "/bin/zsh", + args: ["-l"], + initialCommand: ["codex", "resume", "a b;$HOME", "it's"], + }); + expect(plan).toMatchObject({ + shell: "/bin/zsh", + args: ["-il", "-c", "'codex' 'resume' 'a b;$HOME' 'it'\"'\"'s'"], + }); + }); + + it("uses a valid cwd override and falls back to home for a missing override", () => { + const cwd = tempDirs.make("terminal-resume-cwd-"); + const base = { + agentId: "main", + cwd: "/missing/base", + shell: "/bin/sh", + args: [], + initialCommand: ["claude", "--resume", "id"], + }; + expect(resolveTerminalSpawnPlan({ ...base, cwdOverride: cwd }).cwd).toBe(cwd); + expect( + resolveTerminalSpawnPlan( + { ...base, cwdOverride: "/definitely/missing" }, + { env: { HOME: "/fallback/home" } }, + ).cwd, + ).toBe("/fallback/home"); + }); + + it("spawns the resume executable directly on Windows", () => { + expect( + resolveTerminalSpawnPlan( + { + agentId: "main", + cwd: "/work", + shell: "cmd.exe", + args: [], + initialCommand: ["codex.exe", "resume", "thread"], + }, + { platform: "win32" }, + ), + ).toMatchObject({ shell: "codex.exe", args: ["resume", "thread"] }); + }); +}); diff --git a/src/gateway/terminal/launch.ts b/src/gateway/terminal/launch.ts index f026ef38ad28..8406e575c0e3 100644 --- a/src/gateway/terminal/launch.ts +++ b/src/gateway/terminal/launch.ts @@ -19,13 +19,17 @@ type TerminalLaunchBlock = | { kind: "sandboxed"; agentId: string; mode: "all" }; /** Resolved plan for a host terminal session. */ -type TerminalLaunchPlan = { +export type TerminalLaunchPlan = { agentId: string; cwd: string; shell: string; args: string[]; + initialCommand?: string[]; + cwdOverride?: string; }; +export type TerminalSpawnPlan = Pick; + /** Terminal launch resolution result: either a runnable plan or a block reason. */ export type TerminalLaunchResolution = | { ok: true; plan: TerminalLaunchPlan } @@ -285,6 +289,37 @@ export function buildTerminalEnv(baseEnv: NodeJS.ProcessEnv): Record() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +describe("createNodeRelayBackend", () => { + it("relays progress, input, resize, cancellation, and the node exit result", async () => { + const invokeResult = deferred(); + let onProgress: ((chunk: string) => void) | undefined; + let signal: AbortSignal | undefined; + const sendInvokeInput = vi.fn(); + const registry = { + invoke: vi.fn( + (params: { + onInvokeId?: (id: string) => void; + onProgress?: (chunk: string) => void; + signal?: AbortSignal; + }) => { + onProgress = params.onProgress; + signal = params.signal; + params.onInvokeId?.("invoke-1"); + return invokeResult.promise; + }, + ), + sendInvokeInput, + } as unknown as NodeRegistry; + const backend = await createNodeRelayBackend({ + registry, + nodeId: "node-1", + command: "codex.terminal.resume.v1", + params: { threadId: "thread" }, + }); + const data = vi.fn(); + const exit = vi.fn(); + backend.onData(data); + backend.onExit(exit); + + onProgress?.(""); + onProgress?.("hello"); + expect(data).toHaveBeenCalledWith("hello"); + backend.write("keys"); + backend.resize(100, 30); + expect(sendInvokeInput).toHaveBeenNthCalledWith(1, "invoke-1", { + kind: "data", + data: "keys", + }); + expect(sendInvokeInput).toHaveBeenNthCalledWith(2, "invoke-1", { + kind: "resize", + cols: 100, + rows: 30, + }); + + invokeResult.resolve({ ok: true, payloadJSON: JSON.stringify({ exitCode: 7, signal: 15 }) }); + await vi.waitFor(() => expect(exit).toHaveBeenCalledWith({ exitCode: 7, signal: 15 })); + + backend.kill(); + expect(signal?.aborted).toBe(true); + }); + + it("maps node disconnect failures to terminal errors", async () => { + const registry = { + invoke: vi.fn((params: { onInvokeId?: (id: string) => void }) => { + params.onInvokeId?.("invoke-2"); + return Promise.resolve({ + ok: false, + error: { code: "NOT_CONNECTED", message: "node disconnected" }, + }); + }), + sendInvokeInput: vi.fn(), + } as unknown as NodeRegistry; + const backend = await createNodeRelayBackend({ + registry, + nodeId: "node-1", + command: "anthropic.claude.terminal.resume.v1", + params: {}, + }); + const exit = vi.fn(); + backend.onExit(exit); + await vi.waitFor(() => + expect(exit).toHaveBeenCalledWith({ error: "NOT_CONNECTED: node disconnected" }), + ); + }); +}); diff --git a/src/gateway/terminal/node-relay.ts b/src/gateway/terminal/node-relay.ts new file mode 100644 index 000000000000..bad0130bd60f --- /dev/null +++ b/src/gateway/terminal/node-relay.ts @@ -0,0 +1,145 @@ +import { NODE_DUPLEX_INVOKE_IDLE_TIMEOUT_MS } from "../../infra/node-commands.js"; +import type { NodeRegistry, NodeInvokeResult } from "../node-registry.js"; +import type { TerminalBackend, TerminalBackendExit } from "./backend.js"; + +const DATA_INPUT_CHUNK_BYTES = 2 * 1024; + +function parseExit(result: NodeInvokeResult): TerminalBackendExit { + if (!result.ok) { + const code = result.error?.code ?? "NODE_INVOKE_FAILED"; + const message = result.error?.message ?? "node terminal invoke failed"; + return { error: `${code}: ${message}` }; + } + try { + const raw = + result.payloadJSON ?? + (result.payload === undefined ? undefined : JSON.stringify(result.payload)); + if (!raw) { + return { exitCode: 0 }; + } + const value = JSON.parse(raw) as unknown; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return { exitCode: 0 }; + } + const record = value as Record; + return { + ...(typeof record.exitCode === "number" ? { exitCode: record.exitCode } : {}), + ...(typeof record.signal === "number" ? { signal: record.signal } : {}), + }; + } catch { + return { error: "node terminal returned an invalid exit result" }; + } +} + +function splitInput(data: string): string[] { + const chunks: string[] = []; + let start = 0; + let bytes = 0; + for (let index = 0; index < data.length; index += 1) { + const codePoint = data.codePointAt(index); + if (codePoint === undefined) { + break; + } + const char = String.fromCodePoint(codePoint); + const size = Buffer.byteLength(char, "utf8"); + if (bytes > 0 && bytes + size > DATA_INPUT_CHUNK_BYTES) { + chunks.push(data.slice(start, index)); + start = index; + bytes = 0; + } + bytes += size; + if (char.length === 2) { + index += 1; + } + } + if (start < data.length) { + chunks.push(data.slice(start)); + } + return chunks; +} + +export async function createNodeRelayBackend(params: { + registry: NodeRegistry; + nodeId: string; + command: string; + params: Record; +}): Promise { + let invokeId: string | undefined; + let dataCallback: ((data: string) => void) | undefined; + let exitCallback: ((exit: TerminalBackendExit) => void) | undefined; + const pendingData: string[] = []; + let pendingExit: TerminalBackendExit | undefined; + const abort = new AbortController(); + const result = params.registry + .invoke({ + nodeId: params.nodeId, + command: params.command, + params: params.params, + timeoutMs: 0, + idleTimeoutMs: NODE_DUPLEX_INVOKE_IDLE_TIMEOUT_MS, + signal: abort.signal, + onInvokeId: (id) => { + invokeId = id; + }, + onProgress: (chunk) => { + if (!chunk) { + return; + } + if (dataCallback) { + dataCallback(chunk); + } else { + pendingData.push(chunk); + } + }, + }) + .then(parseExit) + .catch( + (error: unknown): TerminalBackendExit => ({ + error: error instanceof Error ? error.message : String(error), + }), + ) + .then((exit) => { + if (exitCallback) { + exitCallback(exit); + } else { + pendingExit = exit; + } + return exit; + }); + // NodeRegistry invokes onInvokeId synchronously after a successful send, before its first await. + // Failure paths resolve the result instead; keeping that callback synchronous is load-bearing. + await Promise.resolve(); + if (!invokeId) { + const exit = await result; + throw new Error(exit.error ?? "failed to start node terminal invoke"); + } + const activeInvokeId = invokeId; + const send = (payload: unknown) => params.registry.sendInvokeInput(activeInvokeId, payload); + return { + write(data) { + for (const chunk of splitInput(data)) { + send({ kind: "data", data: chunk }); + } + }, + resize(cols, rows) { + send({ kind: "resize", cols, rows }); + }, + kill() { + abort.abort(); + }, + onData(callback) { + dataCallback = callback; + for (const chunk of pendingData.splice(0)) { + callback(chunk); + } + }, + onExit(callback) { + exitCallback = callback; + if (pendingExit) { + const exit = pendingExit; + pendingExit = undefined; + callback(exit); + } + }, + }; +} diff --git a/src/gateway/terminal/output-ring.ts b/src/gateway/terminal/output-ring.ts new file mode 100644 index 000000000000..156a30a1025d --- /dev/null +++ b/src/gateway/terminal/output-ring.ts @@ -0,0 +1,33 @@ +// Bounded terminal scrollback storage shared by session lifecycle operations. +/** + * Raw output, not a screen snapshot: head truncation can start replay mid-escape, + * and the emulator recovers on its next full repaint. + */ +export class TerminalOutputRing { + private chunks: string[] = []; + private total = 0; + + constructor(private readonly cap: number) {} + + push(chunk: string): void { + if (chunk.length >= this.cap) { + this.chunks = [chunk.slice(chunk.length - this.cap)]; + this.total = this.cap; + return; + } + this.chunks.push(chunk); + this.total += chunk.length; + // Evict whole PTY writes so surviving data keeps its original boundaries. + while (this.total > this.cap && this.chunks.length > 1) { + const head = this.chunks.shift(); + if (!head) { + break; + } + this.total -= head.length; + } + } + + snapshot(): string { + return this.chunks.join(""); + } +} diff --git a/src/gateway/terminal/pty.ts b/src/gateway/terminal/pty.ts deleted file mode 100644 index b6787721bc0f..000000000000 --- a/src/gateway/terminal/pty.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Thin, resize-capable PTY wrapper for the operator terminal. -// -// The process supervisor's PTY adapter is shaped for one-shot managed runs and -// hides resize; the operator terminal needs a long-lived, interactive handle, so -// it owns this narrow loader instead of reshaping the supervisor contract. -import type { IPty } from "@lydell/node-pty"; -import { signalProcessTree } from "../../process/kill-tree.js"; - -/** Live PTY handle used by one operator terminal session. */ -export type TerminalPtyHandle = { - pid: number; - write: (data: string) => void; - resize: (cols: number, rows: number) => void; - onData: (listener: (chunk: string) => void) => void; - onExit: (listener: (event: { exitCode: number; signal?: number }) => void) => void; - kill: (signal?: string) => void; -}; - -/** Spawns a PTY process and adapts it to the terminal session handle. */ -export async function spawnTerminalPty(params: { - file: string; - args: string[]; - cwd?: string; - env: Record; - cols: number; - rows: number; -}): Promise { - const { spawn } = await import("@lydell/node-pty"); - const pty = spawn(params.file, params.args, { - name: params.env.TERM ?? "xterm-256color", - cols: params.cols, - rows: params.rows, - cwd: params.cwd, - env: params.env, - }); - return { - get pid() { - return pty.pid; - }, - write: (data) => pty.write(data), - resize: (cols, rows) => pty.resize(cols, rows), - onData: (listener) => { - pty.onData(listener); - }, - onExit: (listener) => { - pty.onExit(listener); - }, - kill: (signal) => killPtyTree(pty, signal), - } satisfies TerminalPtyHandle; -} - -// node-pty's kill only signals the shell; commands it launched (a long-running -// `npm install`, `sleep`, etc.) would survive close/disconnect/shutdown. Signal -// the whole process tree instead, mirroring the process supervisor's PTY adapter. -function killPtyTree(pty: Pick, signal?: string): void { - const sig = (signal ?? "SIGKILL") as NodeJS.Signals; - try { - if ((sig === "SIGKILL" || sig === "SIGTERM") && typeof pty.pid === "number" && pty.pid > 0) { - signalProcessTree(pty.pid, sig); - } else if (process.platform === "win32") { - pty.kill(); - } else { - pty.kill(sig); - } - } catch { - // Process may already be gone; teardown is best-effort. - } -} diff --git a/src/gateway/terminal/session-manager.test.ts b/src/gateway/terminal/session-manager.test.ts index 8741a0d037ee..55575af9b02e 100644 --- a/src/gateway/terminal/session-manager.test.ts +++ b/src/gateway/terminal/session-manager.test.ts @@ -1,6 +1,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { describe, expect, it, vi } from "vitest"; -import type { TerminalPtyHandle } from "./pty.js"; +import type { TerminalPtyHandle } from "../../process/terminal-pty.js"; +import type { TerminalBackend } from "./backend.js"; import { TerminalSessionManager } from "./session-manager.js"; type TerminalOpenRequest = Parameters[0]; @@ -24,6 +25,8 @@ function makeFakePty() { killed: false, write: (data) => handle.writes.push(data), resize: (cols, rows) => handle.resizes.push([cols, rows]), + pause: () => {}, + resume: () => {}, onData: (listener) => { dataListener = listener; }, @@ -54,6 +57,49 @@ function baseRequest(overrides?: Partial): TerminalOpenRequ } describe("TerminalSessionManager", () => { + it("runs relay backends through the same stream, input, resize, and close lifecycle", async () => { + let onData: ((data: string) => void) | undefined; + let onExit: + | ((exit: { exitCode?: number; signal?: number; error?: string }) => void) + | undefined; + const write = vi.fn(); + const resize = vi.fn(); + const kill = vi.fn(); + const backend: TerminalBackend = { + write, + resize, + kill, + onData: (callback) => { + onData = callback; + }, + onExit: (callback) => { + onExit = callback; + }, + }; + const emit = vi.fn(); + const manager = new TerminalSessionManager({ emit }); + const opened = await manager.open(baseRequest({ createBackend: async () => backend })); + if (!opened.ok) { + throw new Error("expected relay backend open"); + } + + onData?.("relay output"); + expect(emit).toHaveBeenCalledWith("conn-1", TERMINAL_EVENT_DATA, { + sessionId: opened.sessionId, + seq: 0, + data: "relay output", + }); + expect(manager.write("conn-1", opened.sessionId, "input")).toBe(true); + expect(write).toHaveBeenCalledWith("input"); + expect(manager.resize("conn-1", opened.sessionId, 120, 40)).toBe(true); + expect(resize).toHaveBeenCalledWith(120, 40); + expect(manager.close("conn-1", opened.sessionId)).toBe(true); + expect(kill).toHaveBeenCalledOnce(); + + onExit?.({ exitCode: 0 }); + expect(manager.size).toBe(0); + }); + it("opens a session and streams output only to the owning connection", async () => { const emit = vi.fn(); const fake = makeFakePty(); diff --git a/src/gateway/terminal/session-manager.ts b/src/gateway/terminal/session-manager.ts index 74bbaf761986..89ab605d4416 100644 --- a/src/gateway/terminal/session-manager.ts +++ b/src/gateway/terminal/session-manager.ts @@ -1,14 +1,17 @@ // Owns the lifecycle of operator terminal sessions: one PTY per open, bound to // the connection that opened it, streamed back over the gateway event channel. import { randomUUID } from "node:crypto"; -import { spawnTerminalPty, type TerminalPtyHandle } from "./pty.js"; +import { + createLocalTerminalBackend, + type LocalTerminalBackendSpawner, + type TerminalBackend, +} from "./backend.js"; +import { TerminalOutputRing } from "./output-ring.js"; /** Emits one terminal event frame to the single owning connection. */ type TerminalEventSink = (connId: string, event: string, payload: unknown) => void; /** Injectable PTY spawner so tests can drive sessions without a real shell. */ -type TerminalSpawner = typeof spawnTerminalPty; - const TERMINAL_EVENT_DATA = "terminal.data" as const; const TERMINAL_EVENT_EXIT = "terminal.exit" as const; @@ -21,7 +24,7 @@ type TerminalSession = { agentId: string; cwd: string; shell: string; - pty: TerminalPtyHandle; + backend: TerminalBackend; seq: number; closed: boolean; createdAtMs: number; @@ -58,46 +61,9 @@ const DEFAULT_MAX_DETACHED_SESSIONS = 8; /** Default grace period before a detached session is killed (seconds). */ export const DEFAULT_TERMINAL_DETACH_SECONDS = 300; -/** - * Bounded ring of recent PTY output. Raw bytes, not a screen snapshot: after - * head truncation a replay can start mid-escape-sequence; emulators recover on - * the next full repaint (prompt, clear, resize-triggered redraw). A true - * server-side VT snapshot would need a terminal emulator per session and is a - * tracked follow-up. - */ -class TerminalOutputRing { - private chunks: string[] = []; - private total = 0; - - constructor(private readonly cap: number) {} - - push(chunk: string): void { - if (chunk.length >= this.cap) { - this.chunks = [chunk.slice(chunk.length - this.cap)]; - this.total = this.cap; - return; - } - this.chunks.push(chunk); - this.total += chunk.length; - // Evict whole chunks (PTY write granularity) so surviving data keeps its - // original boundaries; the ring may briefly dip below cap, never above. - while (this.total > this.cap && this.chunks.length > 1) { - const head = this.chunks.shift(); - if (!head) { - break; - } - this.total -= head.length; - } - } - - snapshot(): string { - return this.chunks.join(""); - } -} - type TerminalSessionManagerOptions = { emit: TerminalEventSink; - spawn?: TerminalSpawner; + spawn?: LocalTerminalBackendSpawner; maxSessions?: number; env?: NodeJS.ProcessEnv; /** @@ -121,6 +87,7 @@ type TerminalOpenRequest = { cols: number; rows: number; env: Record; + createBackend?: () => Promise; }; type TerminalOpenOutcome = @@ -142,7 +109,7 @@ export class TerminalSessionManager { // orphan for a dead connection. private readonly pendingOpens = new Map>(); private readonly emit: TerminalEventSink; - private readonly spawn: TerminalSpawner; + private readonly spawn?: LocalTerminalBackendSpawner; private readonly maxSessions: number; private readonly detachGraceMs: number; private readonly maxDetachedSessions: number; @@ -153,7 +120,7 @@ export class TerminalSessionManager { constructor(options: TerminalSessionManagerOptions) { this.emit = options.emit; - this.spawn = options.spawn ?? spawnTerminalPty; + this.spawn = options.spawn; this.maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS; this.detachGraceMs = options.detachGraceMs ?? 0; this.maxDetachedSessions = options.maxDetachedSessions ?? DEFAULT_MAX_DETACHED_SESSIONS; @@ -178,16 +145,21 @@ export class TerminalSessionManager { this.opening += 1; const token: OpenToken = { agentId: request.agentId }; this.trackPendingOpen(request.connId, token); - let pty: TerminalPtyHandle; + let backend: TerminalBackend; try { - pty = await this.spawn({ - file: request.shell, - args: request.args, - cwd: request.cwd, - env: request.env, - cols: request.cols, - rows: request.rows, - }); + backend = request.createBackend + ? await request.createBackend() + : await createLocalTerminalBackend( + { + file: request.shell, + args: request.args, + cwd: request.cwd, + env: request.env, + cols: request.cols, + rows: request.rows, + }, + this.spawn, + ); } catch (err) { this.opening -= 1; this.untrackPendingOpen(request.connId, token); @@ -202,7 +174,7 @@ export class TerminalSessionManager { // The owning connection disconnected while the shell was spawning; kill it // now rather than register an orphan no one can reach or close. try { - pty.kill(); + backend.kill(); } catch { // Best-effort; the process may already be gone. } @@ -215,7 +187,7 @@ export class TerminalSessionManager { agentId: request.agentId, cwd: request.cwd, shell: request.shell, - pty, + backend, seq: 0, closed: false, createdAtMs: Date.now(), @@ -226,7 +198,7 @@ export class TerminalSessionManager { this.sessions.set(session.id, session); this.indexByConn(request.connId, session.id); - pty.onData((chunk) => { + backend.onData((chunk) => { if (session.closed) { return; } @@ -241,9 +213,13 @@ export class TerminalSessionManager { data: chunk, }); }); - pty.onExit((event) => { + backend.onExit((event) => { const signal = event.signal && event.signal !== 0 ? event.signal : null; - this.finalize(session, "process_exit", { exitCode: event.exitCode ?? null, signal }); + this.finalize(session, event.error ? "error" : "process_exit", { + exitCode: event.exitCode ?? null, + signal, + ...(event.error ? { error: event.error } : {}), + }); }); return { @@ -262,7 +238,7 @@ export class TerminalSessionManager { return false; } try { - session.pty.write(data); + session.backend.write(data); return true; } catch { this.finalize(session, "error", { error: "write failed" }); @@ -277,7 +253,7 @@ export class TerminalSessionManager { return false; } try { - session.pty.resize(cols, rows); + session.backend.resize(cols, rows); return true; } catch { return false; @@ -517,7 +493,7 @@ export class TerminalSessionManager { this.byConn.get(session.connId)?.delete(session.id); } try { - session.pty.kill(); + session.backend.kill(); } catch { // Process may already be gone; the kill is best-effort teardown. } diff --git a/src/infra/node-commands.ts b/src/infra/node-commands.ts index dc5fe028e093..edcc29d56df5 100644 --- a/src/infra/node-commands.ts +++ b/src/infra/node-commands.ts @@ -10,6 +10,11 @@ export const NODE_FS_LIST_DIR_COMMAND = "fs.listDir"; export const NODE_BROWSER_PROXY_COMMAND = "browser.proxy"; export const NODE_MCP_TOOLS_CALL_COMMAND = "mcp.tools.call.v1"; export const NODE_AGENT_CLI_CLAUDE_RUN_COMMAND = "agent.cli.claude.run.v1"; + +// Node duplex heartbeats must arrive before the Gateway relay declares the +// invoke idle, so both processes share this timeout contract. +export const NODE_DUPLEX_INVOKE_IDLE_TIMEOUT_MS = 30_000; + export const NODE_EXEC_APPROVALS_COMMANDS = [ "system.execApprovals.get", "system.execApprovals.set", diff --git a/src/node-host/invoke-agent-cli-claude-handler.ts b/src/node-host/invoke-agent-cli-claude-handler.ts index 54251c23448a..e686d8e31489 100644 --- a/src/node-host/invoke-agent-cli-claude-handler.ts +++ b/src/node-host/invoke-agent-cli-claude-handler.ts @@ -1,4 +1,5 @@ import { createExecApprovalPolicySnapshot } from "../infra/exec-approvals.js"; +import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js"; import type { NodeHostClient } from "./client.js"; import { decodeClaudeCliNodeRunParams, @@ -17,6 +18,7 @@ export type NodeHostInvokeRuntime = { claudePath?: string; handleSystemRun?: typeof handleSystemRunInvoke; signal?: AbortSignal; + pluginCommandIo?: OpenClawPluginNodeHostCommandIo; }; type ClaudeCliNodeInvokeDeps = Pick< diff --git a/src/node-host/invoke-agent-cli-claude-params.ts b/src/node-host/invoke-agent-cli-claude-params.ts index 42772a2030b5..a5f70eb5d5b3 100644 --- a/src/node-host/invoke-agent-cli-claude-params.ts +++ b/src/node-host/invoke-agent-cli-claude-params.ts @@ -97,6 +97,15 @@ function requireBoundedString(value: unknown, label: string, maxBytes: number): return value; } +/** Claude CLI session ids are bounded, non-option argv values. */ +export function validateClaudeSessionId(value: unknown): string { + const sessionId = requireBoundedString(value, "threadId", MAX_ARG_BYTES).trim(); + if (!sessionId || sessionId.startsWith("-")) { + throw new Error("INVALID_REQUEST: threadId must be a Claude session id"); + } + return sessionId; +} + function validateArgs(value: unknown): string[] { if (!Array.isArray(value) || value.length === 0 || value.length > MAX_ARG_COUNT) { throw new Error("INVALID_REQUEST: argv must be a bounded non-empty array"); diff --git a/src/node-host/invoke-agent-cli-claude.ts b/src/node-host/invoke-agent-cli-claude.ts index fe3eeaf84872..ef44db3c69e4 100644 --- a/src/node-host/invoke-agent-cli-claude.ts +++ b/src/node-host/invoke-agent-cli-claude.ts @@ -6,42 +6,15 @@ import path from "node:path"; import { StringDecoder } from "node:string_decoder"; import { signalProcessTree } from "../process/kill-tree.js"; import { resolveSafeChildProcessInvocation } from "../process/windows-command.js"; -import { truncateUtf8Prefix, truncateUtf8Suffix } from "../utils/utf8-truncate.js"; +import { truncateUtf8Suffix } from "../utils/utf8-truncate.js"; import type { NodeHostClient } from "./client.js"; import type { ClaudeCliNodeRunParams } from "./invoke-agent-cli-claude-params.js"; import type { NodeInvokeRequestPayload, RunResult } from "./invoke-types.js"; +import { createNodeInvokeProgressWriter } from "./node-invoke-progress.js"; const OUTPUT_CAP_BYTES = 200_000; const STDERR_TAIL_BYTES = 20_000; -const PROGRESS_CHUNK_BYTES = 16 * 1024; const TERMINAL_EVENT_MAX_BYTES = 1024 * 1024; -const MIN_HEARTBEAT_INTERVAL_MS = 250; -const MAX_HEARTBEAT_INTERVAL_MS = 5_000; - -async function sendProgressChunks( - client: NodeHostClient, - frame: NodeInvokeRequestPayload, - startSeq: number, - text: string, -): Promise { - let seq = startSeq; - let remaining = text; - while (remaining) { - const chunk = truncateUtf8Prefix(remaining, PROGRESS_CHUNK_BYTES); - if (!chunk) { - break; - } - await client.request("node.invoke.progress", { - invokeId: frame.id, - nodeId: frame.nodeId, - seq, - chunk, - }); - seq += 1; - remaining = remaining.slice(chunk.length); - } - return seq; -} function isClaudeResultLine(line: string): boolean { try { @@ -95,13 +68,6 @@ export async function runClaudeCliNodeCommand(params: { let truncated = false; let outputBytes = 0; let stderr = ""; - let progressSeq = 0; - let progressQueue = Promise.resolve(); - let progressError: Error | undefined; - let heartbeatQueued = false; - let heartbeatDirty = false; - let heartbeatTimer: ReturnType | undefined; - let lastProgressAt = 0; const decoder = new StringDecoder("utf8"); const stderrDecoder = new StringDecoder("utf8"); const terminalDecoder = new StringDecoder("utf8"); @@ -133,6 +99,12 @@ export async function runClaudeCliNodeCommand(params: { // Best effort; close/error settles the result. } }; + const progress = createNodeInvokeProgressWriter({ + client: params.client, + frame: params.frame, + idleTimeoutMs: params.request.idleTimeoutMs, + onError: kill, + }); const abortRun = () => { cancelled = true; kill(); @@ -193,57 +165,6 @@ export async function runClaudeCliNodeCommand(params: { terminalLineTouchesTruncation = false; } }; - const queueProgressTask = (stream: typeof child.stdout, task: () => Promise): void => { - stream.pause(); - progressQueue = progressQueue - .then(task) - .catch((error: unknown) => { - progressError = error instanceof Error ? error : new Error(String(error)); - kill(); - }) - .finally(() => stream.resume()); - }; - const heartbeatIntervalMs = Math.max( - MIN_HEARTBEAT_INTERVAL_MS, - Math.min(MAX_HEARTBEAT_INTERVAL_MS, Math.floor(params.request.idleTimeoutMs / 2)), - ); - const queueHeartbeat = () => { - if (settled) { - return; - } - if (heartbeatQueued) { - heartbeatDirty = true; - return; - } - heartbeatQueued = true; - const delayMs = Math.max(0, heartbeatIntervalMs - (Date.now() - lastProgressAt)); - heartbeatTimer = setTimeout(() => { - heartbeatTimer = undefined; - progressQueue = progressQueue - .then(async () => { - await params.client.request("node.invoke.progress", { - invokeId: params.frame.id, - nodeId: params.frame.nodeId, - seq: progressSeq, - chunk: "", - }); - progressSeq += 1; - lastProgressAt = Date.now(); - }) - .catch((error: unknown) => { - progressError = error instanceof Error ? error : new Error(String(error)); - kill(); - }) - .finally(() => { - heartbeatQueued = false; - if (heartbeatDirty && !settled) { - heartbeatDirty = false; - queueHeartbeat(); - } - }); - }, delayMs); - }; - child.stdout.on("data", (raw: Buffer) => { const retained = retain(raw); if (retained.length > 0) { @@ -256,20 +177,17 @@ export async function runClaudeCliNodeCommand(params: { // keep the node-local kill timer on the same signal to avoid orphan runs. resetIdleTimer(); if (retained.length === 0) { - queueHeartbeat(); + progress.queueHeartbeat(); return; } const text = decoder.write(retained); - lastProgressAt = Date.now(); - queueProgressTask(child.stdout, async () => { - progressSeq = await sendProgressChunks(params.client, params.frame, progressSeq, text); - }); + void progress.write(text, child.stdout); }); child.stderr.on("data", (raw: Buffer) => { retain(raw); stderr = truncateUtf8Suffix(`${stderr}${stderrDecoder.write(raw)}`, STDERR_TAIL_BYTES); resetIdleTimer(); - queueHeartbeat(); + progress.queueHeartbeat(); }); child.stdin.on("error", () => {}); child.stdin.end(params.request.stdin ?? ""); @@ -281,19 +199,11 @@ export async function runClaudeCliNodeCommand(params: { settled = true; clearTimeout(hardTimer); clearTimeout(idleTimer); - clearTimeout(heartbeatTimer); - heartbeatDirty = false; + progress.stopHeartbeats(); params.signal?.removeEventListener("abort", abortRun); const finalText = decoder.end(); if (finalText) { - progressQueue = progressQueue.then(async () => { - progressSeq = await sendProgressChunks( - params.client, - params.frame, - progressSeq, - finalText, - ); - }); + void progress.write(finalText); } const terminalText = terminalDecoder.end(); if (terminalText) { @@ -311,22 +221,16 @@ export async function runClaudeCliNodeCommand(params: { terminalResultLine = terminalLineBuffer; } if (truncated && terminalResultLine) { - progressQueue = progressQueue.then(async () => { - progressSeq = await sendProgressChunks( - params.client, - params.frame, - progressSeq, - `\n${terminalResultLine}\n`, - ); - }); + void progress.write(`\n${terminalResultLine}\n`); } - await progressQueue.catch(() => {}); + await progress.flush(); + progress.stop(); const timeoutMessage = idleTimedOut ? "Claude CLI produced no output before the idle timeout" : hardTimedOut ? "Claude CLI exceeded the hard timeout" : ""; - const finalError = progressError ?? error; + const finalError = progress.error ?? error; const cancelledMessage = cancelled ? "Claude CLI invocation cancelled" : ""; resolve({ exitCode: exitCode ?? (idleTimedOut || hardTimedOut ? 124 : cancelled ? 130 : 1), diff --git a/src/node-host/invoke-payload.ts b/src/node-host/invoke-payload.ts index 59e7cde8f168..e9bdcaadd1cd 100644 --- a/src/node-host/invoke-payload.ts +++ b/src/node-host/invoke-payload.ts @@ -1,5 +1,7 @@ import type { NodeInvokeRequestPayload } from "./invoke-types.js"; +const MAX_INVOKE_INPUT_BYTES = 16 * 1024; + export function coerceNodeInvokePayload(payload: unknown): NodeInvokeRequestPayload | null { if (!payload || typeof payload !== "object") { return null; @@ -40,3 +42,29 @@ export function coerceNodeInvokeCancelPayload( ? { invokeId: value.invokeId, nodeId: value.nodeId } : null; } + +export function coerceNodeInvokeInputPayload( + payload: unknown, +): { invokeId: string; nodeId: string; seq: number; payloadJSON: string } | null { + const value = + payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : null; + if ( + !value || + typeof value.id !== "string" || + typeof value.nodeId !== "string" || + !Number.isInteger(value.seq) || + (value.seq as number) < 0 || + typeof value.payloadJSON !== "string" || + Buffer.byteLength(value.payloadJSON, "utf8") > MAX_INVOKE_INPUT_BYTES + ) { + return null; + } + return { + invokeId: value.id, + nodeId: value.nodeId, + seq: value.seq as number, + payloadJSON: value.payloadJSON, + }; +} diff --git a/src/node-host/invoke.ts b/src/node-host/invoke.ts index e3c80c28a39a..ba63fcde8603 100644 --- a/src/node-host/invoke.ts +++ b/src/node-host/invoke.ts @@ -64,7 +64,7 @@ import type { SystemRunParams, } from "./invoke-types.js"; import { NodeHostMcpError, type NodeHostMcpManager } from "./mcp.js"; -import { invokeRegisteredNodeHostCommand } from "./plugin-node-host.js"; +import { invokeRegisteredNodeHostCommand as invokePlugin } from "./plugin-node-host.js"; import { resolveNodeHostedSkillDirectory } from "./skills.js"; const OUTPUT_CAP = 200_000; @@ -708,9 +708,9 @@ async function dispatchInvoke( } try { - const pluginNodeHostResult = await invokeRegisteredNodeHostCommand(command, frame.paramsJSON); - if (pluginNodeHostResult !== null) { - await sendRawPayloadResult(client, frame, pluginNodeHostResult); + const pluginResult = await invokePlugin(command, frame.paramsJSON, runtime.pluginCommandIo); + if (pluginResult !== null) { + await sendRawPayloadResult(client, frame, pluginResult); return; } } catch (err) { diff --git a/src/node-host/node-invoke-progress.test.ts b/src/node-host/node-invoke-progress.test.ts new file mode 100644 index 000000000000..0ef4f8590243 --- /dev/null +++ b/src/node-host/node-invoke-progress.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; +import type { NodeHostClient } from "./client.js"; +import { + createNodeInvokeProgressWriter, + resolveNodeInvokeHeartbeatInterval, +} from "./node-invoke-progress.js"; + +const frame = { + id: "invoke-1", + nodeId: "node-1", + command: "test.duplex", + paramsJSON: null, + timeoutMs: 0, + idempotencyKey: null, +}; + +describe("node invoke progress writer", () => { + it("chunks output to 16 KiB and pauses its producer for backpressure", async () => { + const request = vi.fn(async () => ({})); + const client = { request } as NodeHostClient; + const pausable = { pause: vi.fn(), resume: vi.fn() }; + const writer = createNodeInvokeProgressWriter({ + client, + frame, + idleTimeoutMs: 30_000, + onError: vi.fn(), + }); + + await writer.write("é".repeat(10_000), pausable); + expect(request).toHaveBeenCalledTimes(2); + expect(pausable.pause).toHaveBeenCalledOnce(); + expect(pausable.resume).toHaveBeenCalledOnce(); + for (const [, params] of request.mock.calls as unknown as Array<[string, { chunk: string }]>) { + expect(Buffer.byteLength(params.chunk, "utf8")).toBeLessThanOrEqual(16 * 1024); + } + }); + + it("emits idle heartbeats at the clamped half-timeout interval", async () => { + vi.useFakeTimers(); + try { + const request = vi.fn(async () => ({})); + const writer = createNodeInvokeProgressWriter({ + client: { request } as NodeHostClient, + frame, + idleTimeoutMs: 2_000, + onError: vi.fn(), + }); + writer.startHeartbeats(); + await vi.advanceTimersByTimeAsync(1_000); + expect(request).toHaveBeenCalledWith("node.invoke.progress", { + invokeId: "invoke-1", + nodeId: "node-1", + seq: 0, + chunk: "", + }); + writer.stop(); + await writer.flush(); + } finally { + vi.useRealTimers(); + } + expect(resolveNodeInvokeHeartbeatInterval(100)).toBe(250); + expect(resolveNodeInvokeHeartbeatInterval(60_000)).toBe(5_000); + }); +}); diff --git a/src/node-host/node-invoke-progress.ts b/src/node-host/node-invoke-progress.ts new file mode 100644 index 000000000000..5ae0092c1e0e --- /dev/null +++ b/src/node-host/node-invoke-progress.ts @@ -0,0 +1,132 @@ +import { truncateUtf8Prefix } from "../utils/utf8-truncate.js"; +import type { NodeHostClient } from "./client.js"; +import type { NodeInvokeRequestPayload } from "./invoke-types.js"; + +const PROGRESS_CHUNK_BYTES = 16 * 1024; +const MIN_HEARTBEAT_INTERVAL_MS = 250; +const MAX_HEARTBEAT_INTERVAL_MS = 5_000; + +type Pausable = { pause(): void; resume(): void }; + +export function resolveNodeInvokeHeartbeatInterval(idleTimeoutMs: number): number { + return Math.max( + MIN_HEARTBEAT_INTERVAL_MS, + Math.min(MAX_HEARTBEAT_INTERVAL_MS, Math.floor(idleTimeoutMs / 2)), + ); +} + +export function createNodeInvokeProgressWriter(params: { + client: NodeHostClient; + frame: NodeInvokeRequestPayload; + idleTimeoutMs: number; + onError: (error: Error) => void; +}) { + let seq = 0; + let queue = Promise.resolve(); + let progressError: Error | undefined; + let heartbeatQueued = false; + let heartbeatDirty = false; + let heartbeatTimer: ReturnType | undefined; + let recurringHeartbeats = false; + let stopped = false; + let lastProgressAt = 0; + const heartbeatIntervalMs = resolveNodeInvokeHeartbeatInterval(params.idleTimeoutMs); + + const recordError = (error: unknown) => { + progressError = error instanceof Error ? error : new Error(String(error)); + params.onError(progressError); + }; + + const enqueue = (task: () => Promise, pausable?: Pausable): Promise => { + pausable?.pause(); + queue = queue + .then(task) + .catch(recordError) + .finally(() => pausable?.resume()); + return queue; + }; + + const sendText = async (text: string) => { + let remaining = text; + while (remaining) { + const chunk = truncateUtf8Prefix(remaining, PROGRESS_CHUNK_BYTES); + if (!chunk) { + break; + } + await params.client.request("node.invoke.progress", { + invokeId: params.frame.id, + nodeId: params.frame.nodeId, + seq, + chunk, + }); + seq += 1; + remaining = remaining.slice(chunk.length); + } + }; + + const queueHeartbeat = () => { + if (stopped) { + return; + } + if (heartbeatQueued) { + heartbeatDirty = true; + return; + } + heartbeatQueued = true; + const delayMs = Math.max(0, heartbeatIntervalMs - (Date.now() - lastProgressAt)); + heartbeatTimer = setTimeout(() => { + heartbeatTimer = undefined; + void enqueue(async () => { + await params.client.request("node.invoke.progress", { + invokeId: params.frame.id, + nodeId: params.frame.nodeId, + seq, + chunk: "", + }); + seq += 1; + lastProgressAt = Date.now(); + }).finally(() => { + heartbeatQueued = false; + if ((heartbeatDirty || recurringHeartbeats) && !stopped) { + heartbeatDirty = false; + queueHeartbeat(); + } + }); + }, delayMs); + }; + + return { + write(text: string, pausable?: Pausable): Promise { + if (!text || stopped) { + return queue; + } + lastProgressAt = Date.now(); + return enqueue(() => sendText(text), pausable); + }, + queueHeartbeat, + startHeartbeats(): void { + recurringHeartbeats = true; + queueHeartbeat(); + }, + stopHeartbeats(): void { + recurringHeartbeats = false; + heartbeatDirty = false; + clearTimeout(heartbeatTimer); + heartbeatTimer = undefined; + heartbeatQueued = false; + }, + async flush(): Promise { + await queue.catch(() => {}); + }, + stop(): void { + stopped = true; + recurringHeartbeats = false; + heartbeatDirty = false; + clearTimeout(heartbeatTimer); + heartbeatTimer = undefined; + }, + get error(): Error | undefined { + return progressError; + }, + }; +} diff --git a/src/node-host/plugin-node-host.test.ts b/src/node-host/plugin-node-host.test.ts index 0369a210f79a..6ad8a5183632 100644 --- a/src/node-host/plugin-node-host.test.ts +++ b/src/node-host/plugin-node-host.test.ts @@ -184,4 +184,39 @@ describe("plugin node-host registry", () => { await expect(invokeRegisteredNodeHostCommand("missing.command", null)).resolves.toBeNull(); expect(handle).toHaveBeenCalledWith('{"ok":true}'); }); + + it("gates duplex commands from embedded-worker manifests and supplies their IO context", async () => { + const handle = vi.fn(async (paramsJSON?: string | null) => paramsJSON ?? ""); + const registry = createEmptyPluginRegistry(); + registry.nodeHostCommands = [ + { + pluginId: "terminal", + pluginName: "Terminal", + command: { + command: "terminal.resume.v1", + cap: "terminal", + duplex: true, + handle, + }, + source: "test", + }, + ]; + setActivePluginRegistry(registry); + + expect( + listRegisteredNodeHostCapsAndCommands(availabilityContext, { includeDuplex: false }), + ).toEqual({ caps: [], commands: [], nodePluginTools: [] }); + const io = { + signal: new AbortController().signal, + emitChunk: async () => {}, + onInput: () => {}, + }; + await expect( + invokeRegisteredNodeHostCommand("terminal.resume.v1", '{"threadId":"id"}', io), + ).resolves.toBe('{"threadId":"id"}'); + expect(handle).toHaveBeenCalledWith('{"threadId":"id"}', io); + await expect(invokeRegisteredNodeHostCommand("terminal.resume.v1", null)).rejects.toThrow( + "requires duplex transport", + ); + }); }); diff --git a/src/node-host/plugin-node-host.ts b/src/node-host/plugin-node-host.ts index 1e62664a2d01..f07c257fb371 100644 --- a/src/node-host/plugin-node-host.ts +++ b/src/node-host/plugin-node-host.ts @@ -3,7 +3,10 @@ import type { NodePluginToolDescriptor } from "../../packages/gateway-protocol/s import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginNodeHostCommandRegistration } from "../plugins/registry-types.js"; import { getActivePluginRegistry } from "../plugins/runtime.js"; -import type { OpenClawPluginNodeHostCommandAvailabilityContext } from "../plugins/types.js"; +import type { + OpenClawPluginNodeHostCommandAvailabilityContext, + OpenClawPluginNodeHostCommandIo, +} from "../plugins/types.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; /** @@ -33,6 +36,7 @@ export async function ensureNodeHostPluginRegistry(params: { /** List registered node-host capabilities and command ids in deterministic order. */ export function listRegisteredNodeHostCapsAndCommands( context: OpenClawPluginNodeHostCommandAvailabilityContext, + options: { includeDuplex?: boolean } = {}, ): { caps: string[]; commands: string[]; @@ -43,6 +47,9 @@ export function listRegisteredNodeHostCapsAndCommands( const commands = new Set(); const nodePluginTools = new Map(); for (const entry of registry?.nodeHostCommands ?? []) { + if (entry.command.duplex === true && options.includeDuplex === false) { + continue; + } // Availability belongs to the node-local plugin. Gateway policy still keeps // the command registered so a differently configured remote node can expose it. if (entry.command.isAvailable?.(context) === false) { @@ -113,6 +120,7 @@ function buildNodePluginToolDescriptor( export async function invokeRegisteredNodeHostCommand( command: string, paramsJSON?: string | null, + io?: OpenClawPluginNodeHostCommandIo, ): Promise { const registry = getActivePluginRegistry(); const match = (registry?.nodeHostCommands ?? []).find( @@ -121,5 +129,19 @@ export async function invokeRegisteredNodeHostCommand( if (!match) { return null; } + if (match.command.duplex === true) { + if (!io) { + throw new Error(`node command requires duplex transport: ${command}`); + } + return await match.command.handle(paramsJSON, io); + } return await match.command.handle(paramsJSON); } + +export function isRegisteredNodeHostCommandDuplex(command: string): boolean { + const registry = getActivePluginRegistry(); + return ( + (registry?.nodeHostCommands ?? []).find((entry) => entry.command.command === command)?.command + .duplex === true + ); +} diff --git a/src/node-host/pty-command.test.ts b/src/node-host/pty-command.test.ts new file mode 100644 index 000000000000..a8e5c07f8412 --- /dev/null +++ b/src/node-host/pty-command.test.ts @@ -0,0 +1,82 @@ +import os from "node:os"; +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js"; +import type { TerminalPtyHandle } from "../process/terminal-pty.js"; +import { decodeNodePtyResumeParams, runNodePtyCommand } from "./pty-command.js"; + +describe("node PTY command", () => { + it("validates closed resume params", () => { + const validate = (value: unknown) => { + if (typeof value !== "string" || !value) { + throw new Error("bad thread"); + } + return value; + }; + expect(decodeNodePtyResumeParams('{"threadId":"id","cols":80,"rows":24}', validate)).toEqual({ + threadId: "id", + cols: 80, + rows: 24, + }); + expect(() => + decodeNodePtyResumeParams('{"threadId":"id","cols":80,"rows":24,"argv":["sh"]}', validate), + ).toThrow("unknown terminal resume parameter: argv"); + }); + + it("relays output, data, resize, abort, and exit", async () => { + let onData: ((chunk: string) => void) | undefined; + let onExit: ((event: { exitCode: number; signal?: number }) => void) | undefined; + let onInput: ((payloadJSON: string) => void) | undefined; + const pty = { + pid: 42, + write: vi.fn(), + resize: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), + kill: vi.fn(), + onData: (callback: (chunk: string) => void) => { + onData = callback; + }, + onExit: (callback: (event: { exitCode: number; signal?: number }) => void) => { + onExit = callback; + }, + } satisfies TerminalPtyHandle; + const abort = new AbortController(); + const emitChunk = vi.fn(async () => {}); + const io: OpenClawPluginNodeHostCommandIo = { + signal: abort.signal, + emitChunk, + onInput: (callback) => { + onInput = callback; + }, + }; + const spawn = vi.fn(async () => pty); + const result = runNodePtyCommand( + { + file: "/usr/bin/codex", + args: ["resume", "id"], + cwd: "/missing/catalog/cwd", + cols: 80, + rows: 24, + }, + io, + spawn, + ); + await vi.waitFor(() => expect(spawn).toHaveBeenCalledOnce()); + const spawnCalls = spawn.mock.calls as unknown as Array<[{ cwd?: string }]>; + expect(spawnCalls[0]?.[0].cwd).toBe(os.homedir()); + + onData?.("output"); + await vi.waitFor(() => expect(emitChunk).toHaveBeenCalledWith("output")); + expect(pty.pause).toHaveBeenCalledOnce(); + expect(pty.resume).toHaveBeenCalledOnce(); + onInput?.(JSON.stringify({ kind: "data", data: "keys" })); + onInput?.(JSON.stringify({ kind: "resize", cols: 100, rows: 30 })); + expect(pty.write).toHaveBeenCalledWith("keys"); + expect(pty.resize).toHaveBeenCalledWith(100, 30); + + abort.abort(); + expect(pty.kill).toHaveBeenCalledOnce(); + onExit?.({ exitCode: 130, signal: 15 }); + await expect(result).resolves.toEqual({ exitCode: 130, signal: 15 }); + }); +}); diff --git a/src/node-host/pty-command.ts b/src/node-host/pty-command.ts new file mode 100644 index 000000000000..c0c2f0e0399a --- /dev/null +++ b/src/node-host/pty-command.ts @@ -0,0 +1,163 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js"; +import { spawnTerminalPty } from "../process/terminal-pty.js"; + +export type NodePtyCommandResult = { exitCode: number; signal?: number }; +export type NodePtyResumeParams = { + threadId: string; + cwd?: string; + cols: number; + rows: number; +}; + +type NodePtyInput = { kind: "data"; data: string } | { kind: "resize"; cols: number; rows: number }; + +function resolvePtyCwd(candidate?: string): string { + if (candidate && path.isAbsolute(candidate)) { + try { + if (fs.statSync(candidate).isDirectory()) { + return candidate; + } + } catch { + // Missing/unreadable catalog cwd falls back to the node user's home. + } + } + return os.homedir(); +} + +function decodePtyInput(payloadJSON: string): NodePtyInput | null { + try { + const value = JSON.parse(payloadJSON) as unknown; + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const input = value as Record; + if (input.kind === "data" && typeof input.data === "string") { + return { kind: "data", data: input.data }; + } + if ( + input.kind === "resize" && + Number.isInteger(input.cols) && + Number.isInteger(input.rows) && + (input.cols as number) >= 1 && + (input.cols as number) <= 2000 && + (input.rows as number) >= 1 && + (input.rows as number) <= 2000 + ) { + return { kind: "resize", cols: input.cols as number, rows: input.rows as number }; + } + return null; + } catch { + return null; + } +} + +export function decodeNodePtyResumeParams( + paramsJSON: string | null | undefined, + validateThreadId: (value: unknown) => string, +): NodePtyResumeParams { + let value: unknown; + try { + value = JSON.parse(paramsJSON ?? ""); + } catch { + throw new Error("INVALID_REQUEST: terminal resume params must be valid JSON"); + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("INVALID_REQUEST: terminal resume params must be an object"); + } + const record = value as Record; + const allowed = new Set(["threadId", "cwd", "cols", "rows"]); + const unknown = Object.keys(record).find((key) => !allowed.has(key)); + if (unknown) { + throw new Error(`INVALID_REQUEST: unknown terminal resume parameter: ${unknown}`); + } + const dimension = (candidate: unknown, label: string) => { + if (!Number.isInteger(candidate) || (candidate as number) < 1 || (candidate as number) > 2000) { + throw new Error(`INVALID_REQUEST: ${label} must be an integer from 1 to 2000`); + } + return candidate as number; + }; + if ( + record.cwd !== undefined && + (typeof record.cwd !== "string" || Buffer.byteLength(record.cwd, "utf8") > 4096) + ) { + throw new Error("INVALID_REQUEST: cwd must be a bounded string"); + } + return { + threadId: validateThreadId(record.threadId), + ...(typeof record.cwd === "string" && record.cwd ? { cwd: record.cwd } : {}), + cols: dimension(record.cols, "cols"), + rows: dimension(record.rows, "rows"), + }; +} + +/** Runs one allowlisted plugin-owned command in an interactive node PTY. */ +export async function runNodePtyCommand( + params: { + file: string; + args: string[]; + cwd?: string; + cols: number; + rows: number; + }, + io: OpenClawPluginNodeHostCommandIo, + spawn: typeof spawnTerminalPty = spawnTerminalPty, +): Promise { + if (io.signal.aborted) { + return { exitCode: 130 }; + } + const env = Object.fromEntries( + Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ); + env.TERM ??= "xterm-256color"; + env.OPENCLAW_TERMINAL = "1"; + const pty = await spawn({ + file: params.file, + args: params.args, + cwd: resolvePtyCwd(params.cwd), + env, + cols: params.cols, + rows: params.rows, + }); + let outputQueue = Promise.resolve(); + let settled = false; + const kill = () => pty.kill(); + io.signal.addEventListener("abort", kill, { once: true }); + if (io.signal.aborted) { + kill(); + } + io.onInput((payloadJSON) => { + const input = decodePtyInput(payloadJSON); + if (input?.kind === "data") { + pty.write(input.data); + } else if (input?.kind === "resize") { + pty.resize(input.cols, input.rows); + } + }); + pty.onData((chunk) => { + if (settled) { + return; + } + pty.pause(); + outputQueue = outputQueue.then(() => io.emitChunk(chunk)).finally(() => pty.resume()); + }); + return await new Promise((resolve) => { + pty.onExit((event) => { + if (settled) { + return; + } + settled = true; + io.signal.removeEventListener("abort", kill); + void outputQueue.finally(() => + resolve({ + exitCode: event.exitCode, + ...(event.signal ? { signal: event.signal } : {}), + }), + ); + }); + }); +} diff --git a/src/node-host/runner.test.ts b/src/node-host/runner.test.ts index a1bd47264405..dfef7ad83d9d 100644 --- a/src/node-host/runner.test.ts +++ b/src/node-host/runner.test.ts @@ -20,6 +20,8 @@ const mocks = vi.hoisted(() => ({ mcpConfiguredServerCount: 0, mcpDescriptors: [] as Array>, nodeSkillDescriptors: [] as Array>, + runtimeSteps: [] as string[], + normalizedPath: null as string | null, resolvedExecutables: new Map(), closeMcpManager: vi.fn(async () => undefined), ensureNodeHostConfig: vi.fn(async () => ({ @@ -85,7 +87,12 @@ vi.mock("../infra/executable-path.js", () => ({ })); vi.mock("../infra/path-env.js", () => ({ - ensureOpenClawCliOnPath: vi.fn(), + ensureOpenClawCliOnPath: vi.fn(() => { + mocks.runtimeSteps.push("path"); + if (mocks.normalizedPath) { + process.env.PATH = mocks.normalizedPath; + } + }), })); vi.mock("./config.js", () => ({ @@ -95,19 +102,22 @@ vi.mock("./config.js", () => ({ vi.mock("./plugin-node-host.js", () => ({ ensureNodeHostPluginRegistry: vi.fn(async () => undefined), - listRegisteredNodeHostCapsAndCommands: vi.fn(() => ({ - caps: [], - commands: [], - nodePluginTools: [ - { - pluginId: "test-plugin", - name: "remote_echo", - description: "Echo from node host", - command: "test.echo", - parameters: { type: "object", properties: {} }, - }, - ], - })), + listRegisteredNodeHostCapsAndCommands: vi.fn((context: { env: NodeJS.ProcessEnv }) => { + mocks.runtimeSteps.push(`commands:${context.env.PATH ?? ""}`); + return { + caps: [], + commands: [], + nodePluginTools: [ + { + pluginId: "test-plugin", + name: "remote_echo", + description: "Echo from node host", + command: "test.echo", + parameters: { type: "object", properties: {} }, + }, + ], + }; + }), })); vi.mock("./mcp.js", () => ({ @@ -136,6 +146,8 @@ describe("runNodeHost", () => { mocks.mcpConfiguredServerCount = 0; mocks.mcpDescriptors = []; mocks.nodeSkillDescriptors = []; + mocks.runtimeSteps = []; + mocks.normalizedPath = null; mocks.resolvedExecutables.clear(); vi.clearAllMocks(); mocks.getRuntimeConfig.mockReturnValue({ @@ -173,6 +185,23 @@ describe("runNodeHost", () => { expect(mocks.capturedGatewayClients[0]?.request).not.toHaveBeenCalled(); }); + it("bootstraps PATH before probing plugin command availability", async () => { + const originalPath = process.env.PATH; + mocks.normalizedPath = "/normalized/node/path"; + try { + await expect( + runNodeHost({ + gatewayHost: "127.0.0.1", + gatewayPort: 18789, + }), + ).rejects.toThrow("event loop readiness timeout"); + } finally { + process.env.PATH = originalPath; + } + + expect(mocks.runtimeSteps).toEqual(["path", "commands:/normalized/node/path"]); + }); + it("keeps a ref'd lifetime handle until a ready foreground host stops", async () => { mocks.startGatewayClientWhenEventLoopReady.mockResolvedValueOnce({ ready: true, diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 240e4cf8c8d9..cb968a95ccdb 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -16,7 +16,11 @@ import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { VERSION } from "../version.js"; import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js"; -import { coerceNodeInvokeCancelPayload, coerceNodeInvokePayload } from "./invoke-payload.js"; +import { + coerceNodeInvokeCancelPayload, + coerceNodeInvokeInputPayload, + coerceNodeInvokePayload, +} from "./invoke-payload.js"; import { buildNodeInvokeResultParams } from "./invoke.js"; import { prepareNodeHostRuntime, type NodeHostInventory } from "./runtime.js"; @@ -260,6 +264,13 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { } return; } + if (evt.event === "node.invoke.input") { + const payload = coerceNodeInvokeInputPayload(evt.payload); + if (payload) { + activeRuntime.handleInput(payload.invokeId, payload.seq, payload.payloadJSON); + } + return; + } if (evt.event !== "node.invoke.request") { return; } diff --git a/src/node-host/runtime.test.ts b/src/node-host/runtime.test.ts new file mode 100644 index 000000000000..8829217fbefa --- /dev/null +++ b/src/node-host/runtime.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; +import { dispatchNodeInvokeInput, registerNodeInvokeInputHandler } from "./runtime.js"; + +function createTarget() { + return { + nextInputSeq: 0, + pendingInput: [] as Array<{ payloadJSON: string; bytes: number }>, + pendingInputBytes: 0, + inputFailed: false, + abort: vi.fn(), + }; +} + +describe("node-host invoke input dispatch", () => { + it("buffers frames before registration and flushes them in order", () => { + const input = vi.fn(); + const target = createTarget(); + + expect(dispatchNodeInvokeInput(target, 0, "first")).toBe(true); + expect(dispatchNodeInvokeInput(target, 1, "second")).toBe(true); + expect(input).not.toHaveBeenCalled(); + + registerNodeInvokeInputHandler(target, input); + expect(input.mock.calls).toEqual([["first"], ["second"]]); + }); + + it("drops duplicate sequence numbers", () => { + const input = vi.fn(); + const target = createTarget(); + registerNodeInvokeInputHandler(target, input); + + expect(dispatchNodeInvokeInput(undefined, 0, "unknown")).toBe(false); + expect(dispatchNodeInvokeInput(target, 0, "first")).toBe(true); + expect(dispatchNodeInvokeInput(target, 0, "duplicate")).toBe(false); + expect(dispatchNodeInvokeInput(target, 1, "second")).toBe(true); + expect(input.mock.calls).toEqual([["first"], ["second"]]); + }); + + it("aborts without delivering partial input when the pre-spawn buffer overflows", () => { + const input = vi.fn(); + const target = createTarget(); + const chunk = "x".repeat(16 * 1024 - 1); + + for (let seq = 0; seq < 4; seq += 1) { + expect(dispatchNodeInvokeInput(target, seq, `${seq}${chunk}`)).toBe(true); + } + expect(dispatchNodeInvokeInput(target, 4, `4${chunk}`)).toBe(false); + registerNodeInvokeInputHandler(target, input); + expect(input).not.toHaveBeenCalled(); + expect(target.pendingInput).toEqual([]); + expect(target.abort).toHaveBeenCalledOnce(); + expect(dispatchNodeInvokeInput(target, 5, "continued")).toBe(false); + }); + + it("tolerates sequence gaps", () => { + const input = vi.fn(); + const target = createTarget(); + registerNodeInvokeInputHandler(target, input); + + expect(dispatchNodeInvokeInput(target, 2, "gap")).toBe(true); + expect(dispatchNodeInvokeInput(target, 3, "next")).toBe(true); + expect(input.mock.calls).toEqual([["gap"], ["next"]]); + }); +}); diff --git a/src/node-host/runtime.ts b/src/node-host/runtime.ts index 188a7050ef73..b0443e85c177 100644 --- a/src/node-host/runtime.ts +++ b/src/node-host/runtime.ts @@ -6,17 +6,22 @@ import type { SkillBinTrustEntry } from "../infra/exec-approvals.js"; import { resolveExecutableFromPathEnv } from "../infra/executable-path.js"; import { NODE_AGENT_CLI_CLAUDE_RUN_COMMAND, + NODE_DUPLEX_INVOKE_IDLE_TIMEOUT_MS, NODE_EXEC_APPROVALS_COMMANDS, NODE_FS_LIST_DIR_COMMAND, NODE_MCP_TOOLS_CALL_COMMAND, NODE_SYSTEM_RUN_COMMANDS, } from "../infra/node-commands.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; +import { logDebug } from "../logger.js"; +import type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js"; import type { NodeHostClient } from "./client.js"; import { handleInvoke, type NodeInvokeRequestPayload, type SkillBinsProvider } from "./invoke.js"; import { startNodeHostMcpManager, type NodeHostMcpManager } from "./mcp.js"; +import { createNodeInvokeProgressWriter } from "./node-invoke-progress.js"; import { ensureNodeHostPluginRegistry, + isRegisteredNodeHostCommandDuplex, listRegisteredNodeHostCapsAndCommands, } from "./plugin-node-host.js"; import { scanNodeHostedSkills } from "./skills.js"; @@ -45,11 +50,69 @@ type PreparedNodeHostRuntime = { type ActiveNodeHostRuntime = { invoke(frame: NodeInvokeRequestPayload): Promise; + handleInput(invokeId: string, seq: number, payloadJSON: string): void; cancel(invokeId: string): void; cancelAll(): void; close(): Promise; }; +type NodeInvokeInputTarget = { + nextInputSeq: number; + input?: (payloadJSON: string) => void; + // PTY handlers attach only after async spawn, while Gateway input may arrive immediately. + // Keep that spawn-window input so the sequence cannot wedge before registration. + pendingInput: Array<{ payloadJSON: string; bytes: number }>; + pendingInputBytes: number; + inputFailed: boolean; + abort: (reason: Error) => void; +}; + +const MAX_PENDING_INVOKE_INPUT_BYTES = 64 * 1024; + +export function dispatchNodeInvokeInput( + target: NodeInvokeInputTarget | undefined, + seq: number, + payloadJSON: string, +): boolean { + if (!target || target.inputFailed || seq < target.nextInputSeq) { + return false; + } + if (seq > target.nextInputSeq) { + logDebug(`node-host: input sequence gap: expected ${target.nextInputSeq}, received ${seq}`); + } + target.nextInputSeq = seq + 1; + if (target.input) { + target.input(payloadJSON); + return true; + } + const bytes = Buffer.byteLength(payloadJSON, "utf8"); + if (target.pendingInputBytes + bytes > MAX_PENDING_INVOKE_INPUT_BYTES) { + target.pendingInput.length = 0; + target.pendingInputBytes = 0; + target.inputFailed = true; + target.abort(new Error("terminal input exceeded the 64 KiB pre-spawn buffer")); + logDebug("node-host: aborted invoke after buffered input exceeded 64 KiB"); + return false; + } + target.pendingInput.push({ payloadJSON, bytes }); + target.pendingInputBytes += bytes; + return true; +} + +export function registerNodeInvokeInputHandler( + target: NodeInvokeInputTarget, + input: (payloadJSON: string) => void, +): void { + if (target.inputFailed) { + return; + } + target.input = input; + for (const pending of target.pendingInput.splice(0)) { + input(pending.payloadJSON); + } + target.pendingInputBytes = 0; +} + function resolveExecutablePathFromEnv(bin: string, pathEnv: string): string | null { if (bin.includes("/") || bin.includes("\\")) { return null; @@ -162,8 +225,13 @@ export async function prepareNodeHostRuntime(params?: { const config = params?.config ?? getRuntimeConfig(); const env = params?.env ?? process.env; await ensureNodeHostPluginRegistry({ config, env }); - const pluginNodeHost = listRegisteredNodeHostCapsAndCommands({ config, env }); const pathEnv = ensureNodePathEnv(); + env.PATH = pathEnv; + const duplexEnabled = params?.enableAgentRuns === true; + const pluginNodeHost = listRegisteredNodeHostCapsAndCommands( + { config, env }, + { includeDuplex: duplexEnabled }, + ); // Opt-in and binary resolution are node-local enforcement points. A Gateway // cannot advertise or enable this command on the host's behalf. const claudePath = @@ -196,7 +264,10 @@ export async function prepareNodeHostRuntime(params?: { start({ client, onInventoryChanged }) { const mcpAbort = new AbortController(); const skillBins = new SkillBinsCache(client, pathEnv); - const activeClaudeRuns = new Map(); + const activeInvokes = new Map< + string, + NodeInvokeInputTarget & { controller: AbortController } + >(); let manager: NodeHostMcpManager | undefined; const startup = startNodeHostMcpManager(config.nodeHost?.mcp?.servers, { signal: mcpAbort.signal, @@ -213,32 +284,74 @@ export async function prepareNodeHostRuntime(params?: { }); return { async invoke(frame) { + const duplexCommand = duplexEnabled && isRegisteredNodeHostCommandDuplex(frame.command); const controller = - claudePath && frame.command === NODE_AGENT_CLI_CLAUDE_RUN_COMMAND + (claudePath && frame.command === NODE_AGENT_CLI_CLAUDE_RUN_COMMAND) || duplexCommand ? new AbortController() : undefined; - if (controller) { - activeClaudeRuns.set(frame.id, controller); + const active: (NodeInvokeInputTarget & { controller: AbortController }) | undefined = + controller + ? { + controller, + nextInputSeq: 0, + pendingInput: [], + pendingInputBytes: 0, + inputFailed: false, + abort: (reason) => controller.abort(reason), + } + : undefined; + if (active) { + activeInvokes.set(frame.id, active); } + const progress = duplexCommand + ? createNodeInvokeProgressWriter({ + client, + frame, + idleTimeoutMs: NODE_DUPLEX_INVOKE_IDLE_TIMEOUT_MS, + onError: () => controller?.abort(), + }) + : undefined; + progress?.startHeartbeats(); + const pluginCommandIo: OpenClawPluginNodeHostCommandIo | undefined = + controller && active && progress + ? { + signal: controller.signal, + emitChunk: async (chunk) => await progress.write(chunk), + onInput: (callback) => { + if (activeInvokes.get(frame.id) === active) { + registerNodeInvokeInputHandler(active, callback); + } + }, + } + : undefined; try { await handleInvoke(frame, client, skillBins, manager, { ...(claudePath ? { claudePath } : {}), ...(controller ? { signal: controller.signal } : {}), + ...(pluginCommandIo ? { pluginCommandIo } : {}), }); } finally { - if (controller && activeClaudeRuns.get(frame.id) === controller) { - activeClaudeRuns.delete(frame.id); + progress?.stop(); + await progress?.flush(); + if (active && activeInvokes.get(frame.id) === active) { + activeInvokes.delete(frame.id); } } }, + handleInput(invokeId, seq, payloadJSON) { + const active = activeInvokes.get(invokeId); + if (!dispatchNodeInvokeInput(active, seq, payloadJSON)) { + logDebug(`node-host: dropped inactive or duplicate input for invoke ${invokeId}`); + } + }, cancel(invokeId) { - activeClaudeRuns.get(invokeId)?.abort(); + activeInvokes.get(invokeId)?.controller.abort(); }, cancelAll() { - for (const controller of activeClaudeRuns.values()) { - controller.abort(); + for (const active of activeInvokes.values()) { + active.controller.abort(); } - activeClaudeRuns.clear(); + activeInvokes.clear(); }, async close() { this.cancelAll(); diff --git a/src/plugin-sdk/node-host.ts b/src/plugin-sdk/node-host.ts new file mode 100644 index 000000000000..7de8fbda7fc3 --- /dev/null +++ b/src/plugin-sdk/node-host.ts @@ -0,0 +1,9 @@ +export { + decodeNodePtyResumeParams, + runNodePtyCommand, + type NodePtyCommandResult, + type NodePtyResumeParams, +} from "../node-host/pty-command.js"; +export { validateClaudeSessionId } from "../node-host/invoke-agent-cli-claude-params.js"; +export { resolveExecutableFromPathEnv } from "../infra/executable-path.js"; +export type { OpenClawPluginNodeHostCommandIo } from "../plugins/types.js"; diff --git a/src/plugin-sdk/session-catalog.ts b/src/plugin-sdk/session-catalog.ts index 2a593697ca6f..62e31814f457 100644 --- a/src/plugin-sdk/session-catalog.ts +++ b/src/plugin-sdk/session-catalog.ts @@ -4,6 +4,7 @@ export type { SessionCatalogListProviderParams, SessionCatalogProvider, SessionCatalogReadProviderParams, + SessionCatalogTerminalPlan, } from "../plugins/session-catalog.js"; export type { SessionCatalog, diff --git a/src/plugins/session-catalog.ts b/src/plugins/session-catalog.ts index b6aa7c684431..77976146b4c7 100644 --- a/src/plugins/session-catalog.ts +++ b/src/plugins/session-catalog.ts @@ -22,6 +22,17 @@ export type SessionCatalogContinueProviderParams = Omit< }; export type SessionCatalogArchiveProviderParams = Omit; +export type SessionCatalogTerminalPlan = + | { kind: "local"; argv: string[]; cwd?: string; title?: string } + | { + kind: "node"; + nodeId: string; + command: string; + paramsJSON: string; + cwd?: string; + title?: string; + }; + export type SessionCatalogCreateTarget = { model: string; /** Concrete runtime pinned onto the created session so config reloads cannot retarget it. */ @@ -58,4 +69,8 @@ export type SessionCatalogProvider = { params: SessionCatalogContinueProviderParams, ) => Promise; archive?: (params: SessionCatalogArchiveProviderParams) => Promise<{ ok: true }>; + openTerminal?: (request: { + hostId: string; + threadId: string; + }) => Promise; }; diff --git a/src/plugins/types.node-host.ts b/src/plugins/types.node-host.ts new file mode 100644 index 000000000000..9a8852fa8c75 --- /dev/null +++ b/src/plugins/types.node-host.ts @@ -0,0 +1,39 @@ +// Node-host plugin command contracts, including the opt-in duplex transport. +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +export type OpenClawPluginNodeHostCommandAvailabilityContext = { + /** Node-local configuration used to build this host's Gateway declaration. */ + config: OpenClawConfig; + /** Node-host process environment. */ + env: NodeJS.ProcessEnv; +}; + +export type OpenClawPluginNodeHostCommandIo = { + emitChunk(chunk: string): Promise; + onInput(callback: (payloadJSON: string) => void): void; + signal: AbortSignal; +}; + +type OpenClawPluginNodeHostCommandBase = { + command: string; + cap?: string; + dangerous?: boolean; + /** Return false to omit this command and capability from the node declaration. */ + isAvailable?: (context: OpenClawPluginNodeHostCommandAvailabilityContext) => boolean; + agentTool?: { + name: string; + description: string; + parameters?: Record; + /** Platforms where this tool is allowlisted by default; omit for explicit config only. */ + defaultPlatforms?: Array<"ios" | "android" | "macos" | "windows" | "linux" | "unknown">; + mcp?: { server: string; tool: string }; + }; +}; + +export type OpenClawPluginNodeHostCommand = OpenClawPluginNodeHostCommandBase & { + // Not a discriminated handle signature: a union of different arities makes + // plain `command.handle(params)` uncallable for consumers holding the union. + // The node host enforces io presence for duplex commands at runtime. + duplex?: boolean; + handle: (paramsJSON?: string | null, io?: OpenClawPluginNodeHostCommandIo) => Promise; +}; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index 452c56ecd076..79098dc20770 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -152,6 +152,7 @@ import type { OpenClawPluginToolFactory, OpenClawPluginToolOptions, } from "./tool-types.js"; +import type { OpenClawPluginNodeHostCommand } from "./types.node-host.js"; import type { WebFetchProviderPlugin, WebSearchProviderPlugin } from "./web-provider-types.js"; type ModelProviderRequestTransportOverrides = @@ -2305,35 +2306,11 @@ export type OpenClawPluginReloadRegistration = { noopPrefixes?: string[]; }; -export type OpenClawPluginNodeHostCommandAvailabilityContext = { - /** Node-local configuration used to build this host's Gateway declaration. */ - config: OpenClawConfig; - /** Node-host process environment. */ - env: NodeJS.ProcessEnv; -}; - -export type OpenClawPluginNodeHostCommand = { - command: string; - cap?: string; - dangerous?: boolean; - /** Return false to omit this command and capability from the node declaration. */ - isAvailable?: (context: OpenClawPluginNodeHostCommandAvailabilityContext) => boolean; - agentTool?: { - name: string; - description: string; - parameters?: Record; - /** - * Platforms where this node-hosted agent tool should be allowlisted by - * default. Omit to require explicit `gateway.nodes.allowCommands`. - */ - defaultPlatforms?: Array<"ios" | "android" | "macos" | "windows" | "linux" | "unknown">; - mcp?: { - server: string; - tool: string; - }; - }; - handle: (paramsJSON?: string | null) => Promise; -}; +export type { + OpenClawPluginNodeHostCommand, + OpenClawPluginNodeHostCommandAvailabilityContext, + OpenClawPluginNodeHostCommandIo, +} from "./types.node-host.js"; export type OpenClawPluginNodeInvokeTransportResult = | { diff --git a/src/gateway/terminal/pty.test.ts b/src/process/terminal-pty.test.ts similarity index 54% rename from src/gateway/terminal/pty.test.ts rename to src/process/terminal-pty.test.ts index 86fb0e80ebf2..5a69b5adc34b 100644 --- a/src/gateway/terminal/pty.test.ts +++ b/src/process/terminal-pty.test.ts @@ -5,16 +5,18 @@ const mocks = vi.hoisted(() => ({ spawn: vi.fn(), })); -vi.mock("../../process/kill-tree.js", () => ({ signalProcessTree: mocks.signalProcessTree })); +vi.mock("./kill-tree.js", () => ({ signalProcessTree: mocks.signalProcessTree })); vi.mock("@lydell/node-pty", () => ({ spawn: mocks.spawn })); -const { spawnTerminalPty } = await import("./pty.js"); +const { resolveTerminalPtyInvocation, spawnTerminalPty } = await import("./terminal-pty.js"); function fakePty(pid = 4321) { return { pid, write: vi.fn(), resize: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), onData: vi.fn(), onExit: vi.fn(), kill: vi.fn(), @@ -59,3 +61,37 @@ describe("terminal PTY teardown", () => { expect(() => handle.kill()).not.toThrow(); }); }); + +describe("terminal PTY invocation", () => { + it.each([".cmd", ".bat"])("wraps Windows %s shims through ComSpec", (extension) => { + expect( + resolveTerminalPtyInvocation({ + file: `C:\\Program Files\\Codex\\codex${extension}`, + args: ["resume", "thread title"], + platform: "win32", + comSpec: "C:\\Windows\\System32\\cmd.exe", + }), + ).toEqual({ + file: "C:\\Windows\\System32\\cmd.exe", + args: [ + "/d", + "/s", + "/c", + `""C:\\Program Files\\Codex\\codex${extension}" resume "thread title""`, + ], + }); + }); + + it("keeps executables and non-Windows commands direct", () => { + expect( + resolveTerminalPtyInvocation({ + file: "C:\\tools\\codex.exe", + args: ["resume", "thread"], + platform: "win32", + }), + ).toEqual({ file: "C:\\tools\\codex.exe", args: ["resume", "thread"] }); + expect( + resolveTerminalPtyInvocation({ file: "/tmp/codex.cmd", args: [], platform: "linux" }), + ).toEqual({ file: "/tmp/codex.cmd", args: [] }); + }); +}); diff --git a/src/process/terminal-pty.ts b/src/process/terminal-pty.ts new file mode 100644 index 000000000000..13a80adffb16 --- /dev/null +++ b/src/process/terminal-pty.ts @@ -0,0 +1,92 @@ +import type { IPty } from "@lydell/node-pty"; +import { signalProcessTree } from "./kill-tree.js"; +import { + buildWindowsCmdExeCommandLine, + isWindowsBatchCommand, + resolveTrustedWindowsCmdExe, +} from "./windows-command.js"; + +/** Live PTY handle shared by gateway terminals and node-host commands. */ +export type TerminalPtyHandle = { + pid: number; + write(data: string): void; + resize(cols: number, rows: number): void; + pause(): void; + resume(): void; + onData(listener: (chunk: string) => void): void; + onExit(listener: (event: { exitCode: number; signal?: number }) => void): void; + kill(signal?: string): void; +}; + +export function resolveTerminalPtyInvocation(params: { + file: string; + args: string[]; + platform?: NodeJS.Platform; + comSpec?: string; +}): { file: string; args: string[] } { + const platform = params.platform ?? process.platform; + if (!isWindowsBatchCommand(params.file, platform)) { + return { file: params.file, args: params.args }; + } + return { + file: params.comSpec?.trim() || resolveTrustedWindowsCmdExe(platform), + args: ["/d", "/s", "/c", buildWindowsCmdExeCommandLine(params.file, params.args)], + }; +} + +export async function spawnTerminalPty(params: { + file: string; + args: string[]; + cwd?: string; + env: Record; + cols: number; + rows: number; +}): Promise { + const { spawn } = await import("@lydell/node-pty"); + const comSpec = params.env.ComSpec ?? params.env.COMSPEC; + const invocation = resolveTerminalPtyInvocation({ + file: params.file, + args: params.args, + ...(comSpec ? { comSpec } : {}), + }); + const pty = spawn(invocation.file, invocation.args, { + name: params.env.TERM ?? "xterm-256color", + cols: params.cols, + rows: params.rows, + cwd: params.cwd, + env: params.env, + }); + return { + get pid() { + return pty.pid; + }, + write: (data) => pty.write(data), + resize: (cols, rows) => pty.resize(cols, rows), + pause: () => pty.pause(), + resume: () => pty.resume(), + onData: (listener) => { + pty.onData(listener); + }, + onExit: (listener) => { + pty.onExit(listener); + }, + kill: (signal) => killPtyTree(pty, signal), + } satisfies TerminalPtyHandle; +} + +// A long-running child of the interactive shell must not survive terminal +// close. Signal the process tree, matching the process supervisor contract. +function killPtyTree(pty: Pick, signal?: string): void { + const sig = (signal ?? "SIGKILL") as NodeJS.Signals; + try { + if ((sig === "SIGKILL" || sig === "SIGTERM") && typeof pty.pid === "number" && pty.pid > 0) { + signalProcessTree(pty.pid, sig); + } else if (process.platform === "win32") { + pty.kill(); + } else { + pty.kill(sig); + } + } catch { + // Process may already be gone; teardown is best-effort. + } +} diff --git a/ui/src/app/app-host.ts b/ui/src/app/app-host.ts index 91b16bd9b7d4..af719cc3191a 100644 --- a/ui/src/app/app-host.ts +++ b/ui/src/app/app-host.ts @@ -37,6 +37,7 @@ import { copyToClipboard } from "../lib/clipboard.ts"; import { isGatewayMethodAdvertised } from "../lib/gateway-methods.ts"; import { isWorkboardEnabledInConfigSnapshot } from "../lib/plugin-activation.ts"; import { searchForSession } from "../lib/sessions/index.ts"; +import { isTerminalAvailable } from "../lib/terminal-availability.ts"; import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import { SubscriptionsController } from "../lit/subscriptions-controller.ts"; import { findSettingsSearchBlocks } from "../pages/config/settings-search.ts"; @@ -63,7 +64,12 @@ import { navigationSurfaceIsHidden, renderFloatingUpdateCard } from "./navigatio import { hasOperatorAdminAccess } from "./operator-access.ts"; import { controlUiPublicAssetPath } from "./public-assets.ts"; import { selectRenderedRouteMatch } from "./router-outlet.ts"; -import { NAV_WIDTH_MAX, NAV_WIDTH_MIN, loadSettings } from "./settings.ts"; +import { + NAV_WIDTH_MAX, + NAV_WIDTH_MIN, + loadSettings, + normalizeCatalogOpenTarget, +} from "./settings.ts"; type ShellRouteState = { routeId?: RouteId; @@ -220,19 +226,6 @@ function renderApprovalDocument(runtime: ApplicationRuntime) { `; } -function isTerminalAvailable( - snapshot: ApplicationContext["gateway"]["snapshot"], - terminalEnabled: boolean, -): boolean { - if (!snapshot.connected || !terminalEnabled) { - return false; - } - return ( - hasOperatorAdminAccess(snapshot.hello?.auth ?? null) && - isGatewayMethodAdvertised(snapshot, "terminal.open") === true - ); -} - function isBrowserPanelAvailable(snapshot: ApplicationContext["gateway"]["snapshot"]): boolean { if (!snapshot.connected) { return false; @@ -1256,6 +1249,8 @@ class OpenClawShell extends OpenClawLightDomElement { .enabledRouteIds=${this.enabledRouteIds()} .sessionKey=${this.activeSessionKey} .connected=${gatewaySnapshot.connected} + .terminalAvailable=${terminalAvailable} + .catalogOpenTarget=${normalizeCatalogOpenTarget(uiSettings.catalogOpenTarget)} .canPairDevice=${gatewaySnapshot.connected && hasOperatorAdminAccess(gatewaySnapshot.hello?.auth ?? null)} .sidebarPinnedRoutes=${navigationSnapshot.sidebarPinnedRoutes} diff --git a/ui/src/app/settings-normalizers.ts b/ui/src/app/settings-normalizers.ts new file mode 100644 index 000000000000..fb5d65e1bc67 --- /dev/null +++ b/ui/src/app/settings-normalizers.ts @@ -0,0 +1,19 @@ +// Persisted settings normalizers shared by the settings storage owner. + +/** Unknown shapes fall back to []; stale and duplicate ids are dropped. */ +export function normalizePinnedAgentIds(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + const pinned: string[] = []; + for (const entry of value) { + if (typeof entry !== "string") { + continue; + } + const agentId = entry.trim(); + if (agentId && !pinned.includes(agentId)) { + pinned.push(agentId); + } + } + return pinned; +} diff --git a/ui/src/app/settings.node.test.ts b/ui/src/app/settings.node.test.ts index d759b896a4d0..34ca9d5c85e1 100644 --- a/ui/src/app/settings.node.test.ts +++ b/ui/src/app/settings.node.test.ts @@ -508,6 +508,31 @@ describe("loadSettings default gateway URL derivation", () => { expect(loadSettings().chatSendShortcut).toBe("enter"); }); + it("persists only the non-default catalog open target", () => { + setTestLocation({ + protocol: "https:", + host: "gateway.example:8443", + pathname: "/", + }); + + const gwUrl = expectedGatewayUrl(""); + const scopedKey = `openclaw.control.settings.v1:${gwUrl}`; + expect(loadSettings().catalogOpenTarget).toBe("viewer"); + saveSettings({ ...loadSettings(), catalogOpenTarget: "terminal" }); + expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}").catalogOpenTarget).toBe("terminal"); + expect(loadSettings().catalogOpenTarget).toBe("terminal"); + + saveSettings({ ...loadSettings(), catalogOpenTarget: "viewer" }); + expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}")).not.toHaveProperty( + "catalogOpenTarget", + ); + localStorage.setItem( + scopedKey, + JSON.stringify({ gatewayUrl: gwUrl, catalogOpenTarget: "shell" }), + ); + expect(loadSettings().catalogOpenTarget).toBe("viewer"); + }); + it("persists only a normalized realtime Talk microphone id", () => { setTestLocation({ protocol: "https:", diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index 7a1f6cac88e1..7efbe1ac56a7 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -42,6 +42,7 @@ import { normalizeChatSplitLayout, type ChatSplitLayout } from "../pages/chat/sp import { resolveControlUiBasePath } from "./browser.ts"; import { parseImportedCustomTheme, type ImportedCustomTheme } from "./custom-theme.ts"; import { normalizeGatewayTokenScope } from "./gateway-scope.ts"; +import { normalizePinnedAgentIds } from "./settings-normalizers.ts"; import { parseThemeSelection, type ThemeMode, type ThemeName } from "./theme.ts"; import { normalizeLocalUserIdentity, type LocalUserIdentity } from "./user-identity.ts"; @@ -51,39 +52,24 @@ export type TextScaleStop = (typeof TEXT_SCALE_STOPS)[number]; const CHAT_SEND_SHORTCUTS = ["enter", "modifier-enter"] as const; export type ChatSendShortcut = (typeof CHAT_SEND_SHORTCUTS)[number]; -export function normalizeChatSendShortcut(value: unknown): ChatSendShortcut { - return CHAT_SEND_SHORTCUTS.includes(value as ChatSendShortcut) - ? (value as ChatSendShortcut) - : "enter"; +function normalizeChoice( + values: readonly T[], + fallback: T, +): (value: unknown) => T { + return (value) => (values.includes(value as T) ? (value as T) : fallback); } +export const normalizeChatSendShortcut = normalizeChoice(CHAT_SEND_SHORTCUTS, "enter"); + +const CATALOG_OPEN_TARGETS = ["viewer", "terminal"] as const; +export type CatalogOpenTarget = (typeof CATALOG_OPEN_TARGETS)[number]; + +export const normalizeCatalogOpenTarget = normalizeChoice(CATALOG_OPEN_TARGETS, "viewer"); + const CHAT_WORKSPACE_DOCKS = ["right", "bottom"] as const; export type ChatWorkspaceDock = (typeof CHAT_WORKSPACE_DOCKS)[number]; -export function normalizeChatWorkspaceDock(value: unknown): ChatWorkspaceDock { - return CHAT_WORKSPACE_DOCKS.includes(value as ChatWorkspaceDock) - ? (value as ChatWorkspaceDock) - : "right"; -} - -/** Normalize a persisted pinned-agent list; unknown shapes fall back to [] - and stale/duplicate ids are dropped without a migration. */ -function normalizePinnedAgentIds(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; - } - const pinned: string[] = []; - for (const entry of value) { - if (typeof entry !== "string") { - continue; - } - const agentId = entry.trim(); - if (agentId && !pinned.includes(agentId)) { - pinned.push(agentId); - } - } - return pinned; -} +export const normalizeChatWorkspaceDock = normalizeChoice(CHAT_WORKSPACE_DOCKS, "right"); export function normalizeTextScale(value: unknown, fallback: TextScaleStop = 100): TextScaleStop { if (typeof value !== "number" || !Number.isFinite(value)) { @@ -112,6 +98,7 @@ export type UiSettings = { chatShowToolCalls: boolean; chatPersistCommentary?: boolean; chatSendShortcut?: ChatSendShortcut; + catalogOpenTarget?: CatalogOpenTarget; realtimeTalkInputDeviceId?: string; splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6) chatSplitLayout?: ChatSplitLayout; @@ -346,6 +333,7 @@ export function loadSettings(): UiSettings { chatShowToolCalls: true, chatPersistCommentary: false, chatSendShortcut: "enter", + catalogOpenTarget: "viewer", splitRatio: 0.6, navCollapsed: false, navWidth: NAV_WIDTH_DEFAULT, @@ -396,6 +384,7 @@ export function loadSettings(): UiSettings { ? parsed.chatPersistCommentary : defaults.chatPersistCommentary, chatSendShortcut: normalizeChatSendShortcut(parsed.chatSendShortcut), + catalogOpenTarget: normalizeCatalogOpenTarget(parsed.catalogOpenTarget), realtimeTalkInputDeviceId: normalizeOptionalString(parsed.realtimeTalkInputDeviceId), splitRatio: typeof parsed.splitRatio === "number" && @@ -500,6 +489,9 @@ function persistSettings(next: UiSettings, options: { selectGateway?: boolean } ...(normalizeChatSendShortcut(next.chatSendShortcut) === "modifier-enter" ? { chatSendShortcut: "modifier-enter" as const } : {}), + ...(normalizeCatalogOpenTarget(next.catalogOpenTarget) === "terminal" + ? { catalogOpenTarget: "terminal" as const } + : {}), ...(normalizeOptionalString(next.realtimeTalkInputDeviceId) ? { realtimeTalkInputDeviceId: normalizeOptionalString(next.realtimeTalkInputDeviceId) } : {}), diff --git a/ui/src/components/app-sidebar-catalog-menu.ts b/ui/src/components/app-sidebar-catalog-menu.ts new file mode 100644 index 000000000000..c262c41b9899 --- /dev/null +++ b/ui/src/components/app-sidebar-catalog-menu.ts @@ -0,0 +1,77 @@ +// Owns catalog-row menu state, actions, focus anchor, and rendering for AppSidebar. +import { html, nothing } from "lit"; +import { openCatalogSessionInTerminal } from "../lib/sessions/catalog-terminal.ts"; +import type { CatalogSessionMenuRequest } from "./app-sidebar-session-catalogs.ts"; +import "./catalog-session-menu.ts"; +import type { CatalogSessionMenuAction } from "./catalog-session-menu.ts"; + +type SidebarCatalogSessionMenuState = CatalogSessionMenuRequest & { x: number; y: number }; + +export class SidebarCatalogMenuController { + private state: SidebarCatalogSessionMenuState | null = null; + private trigger: HTMLElement | null = null; + + constructor( + private readonly hooks: { + beforeOpen: () => void; + requestUpdate: () => void; + terminalAvailable: () => boolean; + navigate: (search: string) => void; + }, + ) {} + + get isOpen(): boolean { + return this.state !== null; + } + + open( + request: CatalogSessionMenuRequest, + x: number, + y: number, + trigger: HTMLElement | null = null, + ): void { + this.hooks.beforeOpen(); + this.trigger = trigger; + this.state = { ...request, x, y }; + this.hooks.requestUpdate(); + } + + close(): void { + if (!this.state && !this.trigger) { + return; + } + this.trigger = null; + this.state = null; + this.hooks.requestUpdate(); + } + + private handleAction( + menu: SidebarCatalogSessionMenuState, + action: CatalogSessionMenuAction, + ): void { + if (action === "terminal") { + if (menu.canOpenTerminal && this.hooks.terminalAvailable()) { + openCatalogSessionInTerminal(menu.key); + } + return; + } + this.hooks.navigate(menu.search); + } + + render() { + const menu = this.state; + if (!menu) { + return nothing; + } + return html` + this.handleAction(menu, action)} + .onClose=${() => this.close()} + > + `; + } +} diff --git a/ui/src/components/app-sidebar-session-catalogs.ts b/ui/src/components/app-sidebar-session-catalogs.ts index b2994d6cb1c4..34e5ec4a5df0 100644 --- a/ui/src/components/app-sidebar-session-catalogs.ts +++ b/ui/src/components/app-sidebar-session-catalogs.ts @@ -10,7 +10,10 @@ import { pathForRoute } from "../app-route-paths.ts"; import type { ApplicationNavigationOptions } from "../app/context.ts"; import { t } from "../i18n/index.ts"; import { formatRelativeTimestamp } from "../lib/format.ts"; -import type { CatalogSessionContinuedDetail } from "../lib/sessions/catalog-key.ts"; +import type { + CatalogSessionContinuedDetail, + CatalogSessionKey, +} from "../lib/sessions/catalog-key.ts"; import { buildCatalogSessionKey } from "../lib/sessions/catalog-key.ts"; import { searchForSession } from "../lib/sessions/index.ts"; import type { NewSessionTarget } from "../pages/new-session/location.ts"; @@ -48,6 +51,12 @@ export type CatalogBackingSessionDisplay = { title: string; }; +export type CatalogSessionMenuRequest = { + key: CatalogSessionKey; + search: string; + canOpenTerminal: boolean; +}; + /** Stamps a freshly adopted session key onto its catalog row so the sidebar binds it before the next catalog poll confirms the adoption. */ export function bindAdoptedCatalogSession( @@ -89,6 +98,15 @@ type SessionCatalogGroupsParams = { onLoadMore: (catalogId: string) => void; onOpenNewSession?: (agentId: string, target?: NewSessionTarget) => void; onNavigate?: (routeId: NavigationRouteId, options?: ApplicationNavigationOptions) => void; + catalogOpenTarget: "viewer" | "terminal"; + terminalAvailable: boolean; + onOpenTerminal: (key: CatalogSessionKey) => void; + onOpenMenu: ( + request: CatalogSessionMenuRequest, + x: number, + y: number, + trigger?: HTMLElement, + ) => void; }; function renderCatalogHeaderStatus(hasActiveRun: boolean, hasUnread: boolean) { @@ -235,13 +253,12 @@ function renderCatalogSessionRow( title: `${label} · ${host.label}`, }); } - const key = - session.openClawSessionKey ?? - buildCatalogSessionKey({ - catalogId: catalog.id, - hostId: host.hostId, - threadId: session.threadId, - }); + const catalogKey = { + catalogId: catalog.id, + hostId: host.hostId, + threadId: session.threadId, + } satisfies CatalogSessionKey; + const key = session.openClawSessionKey ?? buildCatalogSessionKey(catalogKey); const search = searchForSession(key); const href = `${pathForRoute("chat", params.basePath)}${search}`; // The catalog header already names the source; only a paired node's @@ -253,12 +270,25 @@ function renderCatalogSessionRow( typeof rawTimestamp === "number" && rawTimestamp < 1_000_000_000_000 ? rawTimestamp * 1000 : rawTimestamp; + const canOpenTerminal = session.canOpenTerminal === true && params.terminalAvailable; + const openTerminal = () => params.onOpenTerminal(catalogKey); + const openMenu = (x: number, y: number, trigger?: HTMLElement) => + params.onOpenMenu( + { key: catalogKey, search, canOpenTerminal: session.canOpenTerminal === true }, + x, + y, + trigger, + ); return html` `; } diff --git a/ui/src/components/app-sidebar.test.ts b/ui/src/components/app-sidebar.test.ts index de158ec76170..d9a685eafec3 100644 --- a/ui/src/components/app-sidebar.test.ts +++ b/ui/src/components/app-sidebar.test.ts @@ -19,12 +19,13 @@ import { import { CATALOG_SESSION_CONTINUED_EVENT } from "../lib/sessions/catalog-key.ts"; import type { SessionCapability } from "../lib/sessions/index.ts"; import { createStorageMock } from "../test-helpers/storage.ts"; -import "./app-sidebar.ts"; import { LOBSTER_LOGO_VISIT_EVENT, createLobsterPetLook, type LobsterLogoVisitDetail, } from "./lobster-pet.ts"; +import "./app-sidebar.ts"; +import { TERMINAL_PANEL_TOGGLE_EVENT } from "./panel-toggle-contract.ts"; type SessionGroupMutationResult = Awaited>; type SessionDeleteResult = Awaited>; @@ -53,6 +54,8 @@ if (!customElements.get(PROVIDER_ELEMENT_NAME)) { type SidebarLifecycleState = HTMLElement & { connected: boolean; + terminalAvailable: boolean; + catalogOpenTarget: "viewer" | "terminal"; canPairDevice: boolean; pinnedAgentIds: readonly string[]; sessionKey: string; @@ -2686,6 +2689,77 @@ describe("AppSidebar catalog session rows", () => { } }); + it("routes terminal-preferred clicks to a typed terminal toggle", async () => { + vi.useFakeTimers(); + try { + const { sidebar } = await mountWithCatalog( + catalogList([{ threadId: "thread-1", name: "Resume me", canOpenTerminal: true }]), + ["agent:main:main"], + ); + sidebar.catalogOpenTarget = "terminal"; + sidebar.terminalAvailable = true; + const navigate = vi.fn(); + sidebar.onNavigate = navigate; + let detail: unknown; + const listener = (event: Event) => { + detail = (event as CustomEvent).detail; + }; + window.addEventListener(TERMINAL_PANEL_TOGGLE_EVENT, listener); + try { + await sidebar.updateComplete; + (sidebar.querySelector('[data-session-key*="thread-1"] a') as HTMLElement).click(); + } finally { + window.removeEventListener(TERMINAL_PANEL_TOGGLE_EVENT, listener); + } + expect(detail).toEqual({ + open: true, + catalog: { catalogId: "codex", hostId: "gateway:local", threadId: "thread-1" }, + }); + expect(navigate).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("falls back to the viewer and disables the terminal menu item when ineligible", async () => { + vi.useFakeTimers(); + try { + const { sidebar } = await mountWithCatalog( + catalogList([{ threadId: "thread-1", name: "View me", canOpenTerminal: false }]), + ["agent:main:main"], + ); + sidebar.catalogOpenTarget = "terminal"; + sidebar.terminalAvailable = true; + const navigate = vi.fn(); + sidebar.onNavigate = navigate; + await sidebar.updateComplete; + const row = sidebar.querySelector('[data-session-key*="thread-1"]') as HTMLElement; + (row.querySelector("a") as HTMLElement).click(); + expect(navigate).toHaveBeenCalledWith("chat", { + search: "?session=catalog%3Acodex%3Agateway%253Alocal%3Athread-1", + }); + row.dispatchEvent( + new MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + clientX: 20, + clientY: 30, + }), + ); + await sidebar.updateComplete; + const menu = sidebar.querySelector("openclaw-catalog-session-menu") as HTMLElement & { + updateComplete: Promise; + }; + await menu.updateComplete; + const items = menu.querySelectorAll("wa-dropdown-item"); + expect(items).toHaveLength(2); + expect(items[1]?.disabled).toBe(true); + expect(row.querySelector("[data-catalog-session-menu]")).not.toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + it("marks the routed catalog session row active without a phantom chat row", async () => { vi.useFakeTimers(); try { diff --git a/ui/src/components/app-sidebar.ts b/ui/src/components/app-sidebar.ts index 190b2012c58a..6bb011c09bf0 100644 --- a/ui/src/components/app-sidebar.ts +++ b/ui/src/components/app-sidebar.ts @@ -25,6 +25,7 @@ import { } from "../app/context.ts"; import { beginNativeWindowDragFromTopInset } from "../app/native-window-drag.ts"; import { controlUiPublicAssetPath } from "../app/public-assets.ts"; +import type { CatalogOpenTarget } from "../app/settings.ts"; import type { ThemeMode } from "../app/theme.ts"; import { t } from "../i18n/index.ts"; import "./menu-surface.ts"; @@ -50,6 +51,7 @@ import { CATALOG_SESSION_CONTINUED_EVENT, type CatalogSessionContinuedDetail, } from "../lib/sessions/catalog-key.ts"; +import { openCatalogSessionInTerminal } from "../lib/sessions/catalog-terminal.ts"; import { reorderSessionCustomGroups } from "../lib/sessions/custom-groups.ts"; import { readSessionDragData, @@ -85,6 +87,7 @@ import { SubscriptionsController } from "../lit/subscriptions-controller.ts"; import { getSafeLocalStorage } from "../local-storage.ts"; import type { NewSessionTarget } from "../pages/new-session/location.ts"; import { renderSidebarAgentMenu } from "./app-sidebar-agent-menu.ts"; +import { SidebarCatalogMenuController } from "./app-sidebar-catalog-menu.ts"; import { isSidebarRouteActive, renderSidebarCustomizeMenu, @@ -247,6 +250,8 @@ class AppSidebar extends OpenClawLightDomContentsElement { @property({ attribute: false }) activePluginTabId = ""; @property({ attribute: false }) enabledRouteIds?: readonly NavigationRouteId[]; @property({ attribute: false }) connected = false; + @property({ attribute: false }) terminalAvailable = false; + @property({ attribute: false }) catalogOpenTarget: CatalogOpenTarget = "viewer"; @property({ attribute: false }) canPairDevice = false; @property({ attribute: false }) sessionKey = ""; @property({ attribute: false }) sidebarPinnedRoutes: readonly SidebarNavRoute[] = @@ -312,6 +317,14 @@ class AppSidebar extends OpenClawLightDomContentsElement { private customizeMenuTrigger: HTMLElement | null = null; private moreMenuTrigger: HTMLElement | null = null; private sessionMenuTrigger: HTMLElement | null = null; + private readonly catalogMenu = new SidebarCatalogMenuController({ + // Closing every transient menu (catalog included; it is not open yet) keeps + // one popover at a time without re-listing each sibling close here. + beforeOpen: () => void this.dismissTransientMenus(), + requestUpdate: () => this.requestUpdate(), + terminalAvailable: () => this.terminalAvailable, + navigate: (search) => this.onNavigate?.("chat", { search }), + }); // Guards the async work fetch: a menu reopened for another session must not // adopt a stale response. private sessionMenuWorkVersion = 0; @@ -721,6 +734,7 @@ class AppSidebar extends OpenClawLightDomContentsElement { this.customizeMenuPosition || this.moreMenuPosition || this.sessionMenu || + this.catalogMenu.isOpen || this.sessionGroupMenu || this.sessionSortMenuPosition || this.agentMenuPosition, @@ -728,6 +742,7 @@ class AppSidebar extends OpenClawLightDomContentsElement { this.closeCustomizeMenu(); this.closeMoreMenu(); this.closeSessionMenu(); + this.catalogMenu.close(); this.closeSessionGroupMenu(); this.closeSessionSortMenu(); this.closeAgentMenu(); @@ -1369,11 +1384,7 @@ class AppSidebar extends OpenClawLightDomContentsElement { // Clamp so the fixed-position menu never overflows the viewport. const menuWidth = 240; const menuMaxHeight = 420; - this.closeMoreMenu(); - this.closeSessionMenu(); - this.closeSessionGroupMenu(); - this.closeSessionSortMenu(); - this.closeAgentMenu(); + this.dismissTransientMenus(); this.customizeMenuTrigger = trigger; this.customizeMenuPosition = { x: Math.max(8, Math.min(x, window.innerWidth - menuWidth - 8)), @@ -1399,11 +1410,7 @@ class AppSidebar extends OpenClawLightDomContentsElement { const menuWidth = 240; const menuMaxHeight = 420; const rect = trigger.getBoundingClientRect(); - this.closeCustomizeMenu(); - this.closeSessionMenu(); - this.closeSessionGroupMenu(); - this.closeSessionSortMenu(); - this.closeAgentMenu(); + this.dismissTransientMenus(); this.moreMenuTrigger = trigger; this.moreMenuPosition = { x: Math.max(8, Math.min(rect.left, window.innerWidth - menuWidth - 8)), @@ -1440,11 +1447,7 @@ class AppSidebar extends OpenClawLightDomContentsElement { y: number, trigger: HTMLElement | null = null, ) { - this.closeCustomizeMenu(); - this.closeMoreMenu(); - this.closeSessionGroupMenu(); - this.closeSessionSortMenu(); - this.closeAgentMenu(); + this.dismissTransientMenus(); this.sessionMenuTrigger = trigger; this.sessionMenu = { session, x, y }; this.loadSessionMenuWork(session); @@ -1489,11 +1492,7 @@ class AppSidebar extends OpenClawLightDomContentsElement { private openSessionGroupMenu(group: string, x: number, y: number, trigger: HTMLElement | null) { const menuWidth = 224; const menuMaxHeight = 160; - this.closeCustomizeMenu(); - this.closeMoreMenu(); - this.closeSessionMenu(); - this.closeSessionSortMenu(); - this.closeAgentMenu(); + this.dismissTransientMenus(); this.sessionGroupMenuTrigger = trigger; this.sessionGroupMenu = { group, @@ -1519,11 +1518,7 @@ class AppSidebar extends OpenClawLightDomContentsElement { const menuWidth = 200; const menuMaxHeight = 280; const rect = trigger.getBoundingClientRect(); - this.closeCustomizeMenu(); - this.closeMoreMenu(); - this.closeSessionMenu(); - this.closeSessionGroupMenu(); - this.closeAgentMenu(); + this.dismissTransientMenus(); this.sessionSortMenuTrigger = trigger; this.sessionSortMenuPosition = { x: Math.max(8, Math.min(rect.right, window.innerWidth - menuWidth - 8)), @@ -2891,6 +2886,10 @@ class AppSidebar extends OpenClawLightDomContentsElement { onLoadMore: (catalogId) => void this.loadMoreSessionCatalog(catalogId), onOpenNewSession: this.onOpenNewSession, onNavigate: this.onNavigate, + catalogOpenTarget: this.catalogOpenTarget, + terminalAvailable: this.terminalAvailable, + onOpenTerminal: (key) => openCatalogSessionInTerminal(key), + onOpenMenu: (request, x, y, trigger) => this.catalogMenu.open(request, x, y, trigger), }); } @@ -3038,7 +3037,8 @@ class AppSidebar extends OpenClawLightDomContentsElement { ${this.renderCustomizeMenu()} ${this.renderMoreMenu()} ${this.renderAgentMenu()} - ${this.renderSessionMenu()} ${this.renderSessionGroupMenu()} ${this.renderSessionSortMenu()} + ${this.renderSessionMenu()} ${this.catalogMenu.render()} ${this.renderSessionGroupMenu()} + ${this.renderSessionSortMenu()} `; } diff --git a/ui/src/components/catalog-session-menu.test.ts b/ui/src/components/catalog-session-menu.test.ts new file mode 100644 index 000000000000..cef30910d2c1 --- /dev/null +++ b/ui/src/components/catalog-session-menu.test.ts @@ -0,0 +1,89 @@ +/* @vitest-environment jsdom */ + +import { html, render } from "lit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import "./catalog-session-menu.ts"; +import type { CatalogSessionMenuAction } from "./catalog-session-menu.ts"; + +type CatalogMenuElement = HTMLElement & { updateComplete: Promise }; +type CatalogMenuItem = HTMLElement & { disabled: boolean; updateComplete: Promise }; + +const containers: HTMLElement[] = []; + +afterEach(() => { + for (const container of containers.splice(0)) { + container.remove(); + } +}); + +describe("catalog session menu", () => { + it("renders an anchored Web Awesome dropdown and focuses the first item", async () => { + const container = document.createElement("div"); + containers.push(container); + document.body.append(container); + render(html``, container); + const menu = container.querySelector("openclaw-catalog-session-menu") as CatalogMenuElement; + await menu.updateComplete; + const dropdown = menu.querySelector("wa-dropdown"); + const items = [...menu.querySelectorAll("wa-dropdown-item")]; + + await Promise.resolve(); + expect(dropdown?.open).toBe(true); + expect(document.activeElement).toBe(items[0]); + expect(items.map((item) => item.getAttribute("value"))).toEqual(["viewer", "terminal"]); + }); + + it.each([ + [0, "viewer"], + [1, "terminal"], + ] as const)("dispatches item %s before synchronous close", async (index, expected) => { + const container = document.createElement("div"); + containers.push(container); + document.body.append(container); + let backingState: { open: true } | null = { open: true }; + const order: string[] = []; + const onAction = vi.fn((action: CatalogSessionMenuAction) => { + order.push(backingState ? action : "cleared"); + }); + render( + html` { + backingState = null; + order.push("close"); + }} + >`, + container, + ); + const menu = container.querySelector("openclaw-catalog-session-menu") as CatalogMenuElement; + await menu.updateComplete; + + menu.querySelectorAll("wa-dropdown-item")[index]?.click(); + + expect(onAction).toHaveBeenCalledWith(expected); + expect(order).toEqual([expected, "close"]); + expect(backingState).toBeNull(); + }); + + it("disables terminal selection with the unavailable reason", async () => { + const onAction = vi.fn(); + const container = document.createElement("div"); + containers.push(container); + document.body.append(container); + render( + html``, + container, + ); + const menu = container.querySelector("openclaw-catalog-session-menu") as CatalogMenuElement; + await menu.updateComplete; + const terminal = menu.querySelector('wa-dropdown-item[value="terminal"]'); + + expect(terminal?.disabled).toBe(true); + expect(terminal?.title).toBe("Terminal opening is unavailable for this session."); + terminal?.click(); + expect(onAction).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/src/components/catalog-session-menu.ts b/ui/src/components/catalog-session-menu.ts new file mode 100644 index 000000000000..114ee2766f90 --- /dev/null +++ b/ui/src/components/catalog-session-menu.ts @@ -0,0 +1,116 @@ +import { html } from "lit"; +import { property } from "lit/decorators.js"; +import { t } from "../i18n/index.ts"; +import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; +import { icons } from "./icons.ts"; +import { promoteToPopoverTopLayer } from "./menu-surface.ts"; +import "./web-awesome.ts"; + +export type CatalogSessionMenuAction = "viewer" | "terminal"; + +class CatalogSessionMenu extends OpenClawLightDomElement { + @property({ attribute: false }) x = 0; + @property({ attribute: false }) y = 0; + @property({ attribute: false }) trigger: HTMLElement | null = null; + @property({ attribute: false }) terminalDisabled = false; + @property({ attribute: false }) onAction: (action: CatalogSessionMenuAction) => void = () => {}; + @property({ attribute: false }) onClose: () => void = () => {}; + + override connectedCallback() { + super.connectedCallback(); + document.addEventListener("keydown", this.handleDocumentKeydown, true); + promoteToPopoverTopLayer(this); + } + + override disconnectedCallback() { + document.removeEventListener("keydown", this.handleDocumentKeydown, true); + super.disconnectedCallback(); + } + + protected override firstUpdated(): void { + const dropdown = this.querySelector }>( + "wa-dropdown", + ); + void Promise.resolve(dropdown?.updateComplete).then(() => { + this.querySelector("wa-dropdown-item:not([disabled])")?.focus(); + }); + } + + private readonly handleDocumentKeydown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + this.trigger?.focus(); + this.onClose(); + } + }; + + private run(action: CatalogSessionMenuAction) { + // Dispatch while the controller still owns the menu snapshot; close clears it synchronously. + this.onAction(action); + this.onClose(); + } + + private readonly handleSelect = ( + event: CustomEvent<{ item: { value?: CatalogSessionMenuAction } }>, + ) => { + event.preventDefault(); + const action = event.detail.item.value; + if (action) { + this.run(action); + } + }; + + private readonly handleAfterHide = (event: Event) => { + if (event.currentTarget instanceof Node && event.currentTarget.isConnected) { + this.onClose(); + } + }; + + override render() { + const menuWidth = 240; + const menuMaxHeight = 112; + const x = Math.max(8, Math.min(this.x, window.innerWidth - menuWidth - 8)); + const y = Math.max(8, Math.min(this.y, window.innerHeight - menuMaxHeight - 8)); + const menuLabel = t("chat.catalog.sessionMenu"); + return html` + + + + + ${t("chat.catalog.openInOpenClaw")} + + + + ${t("chat.catalog.openInTerminal")} + + + `; + } +} + +if (!customElements.get("openclaw-catalog-session-menu")) { + customElements.define("openclaw-catalog-session-menu", CatalogSessionMenu); +} diff --git a/ui/src/components/panel-toggle-contract.ts b/ui/src/components/panel-toggle-contract.ts index 91175a0348db..16d2a2fc5496 100644 --- a/ui/src/components/panel-toggle-contract.ts +++ b/ui/src/components/panel-toggle-contract.ts @@ -4,6 +4,11 @@ export const BROWSER_PANEL_TOGGLE_EVENT = "openclaw:browser-toggle"; export type TerminalPanelToggleDetail = { dock?: "bottom" | "right"; open?: boolean; + catalog?: { + catalogId: string; + hostId: string; + threadId: string; + }; }; export type BrowserPanelToggleDetail = { diff --git a/ui/src/components/terminal/terminal-connection.test.ts b/ui/src/components/terminal/terminal-connection.test.ts index 14a12909be3f..99f8a304304a 100644 --- a/ui/src/components/terminal/terminal-connection.test.ts +++ b/ui/src/components/terminal/terminal-connection.test.ts @@ -69,6 +69,30 @@ describe("TerminalConnection", () => { }); }); + it("forwards a typed catalog reference and preserves the returned title", async () => { + const client = makeFakeClient(); + client.nextResponse = { + sessionId: "s1", + agentId: "main", + shell: "/bin/zsh", + cwd: "/work", + confined: false, + title: "codex resume 0d5c…", + }; + const conn = new TerminalConnection(client); + const catalog = { catalogId: "codex", hostId: "node:mac", threadId: "thread" }; + const result = await conn.open( + { cols: 100, rows: 30, catalog }, + { onData: () => {}, onExit: () => {} }, + ); + + expect(client.requests[0]).toEqual({ + method: "terminal.open", + params: { cols: 100, rows: 30, catalog }, + }); + expect(result.title).toBe("codex resume 0d5c…"); + }); + it("does not deliver data to the wrong session", async () => { const client = makeFakeClient(); const conn = new TerminalConnection(client); diff --git a/ui/src/components/terminal/terminal-connection.ts b/ui/src/components/terminal/terminal-connection.ts index 0911efc87228..b718820a46ab 100644 --- a/ui/src/components/terminal/terminal-connection.ts +++ b/ui/src/components/terminal/terminal-connection.ts @@ -14,6 +14,13 @@ type TerminalOpenResult = { shell: string; cwd: string; confined: boolean; + title?: string; +}; + +type TerminalCatalogReference = { + catalogId: string; + hostId: string; + threadId: string; }; type TerminalAttachResult = TerminalOpenResult & { @@ -118,7 +125,7 @@ export class TerminalConnection { /** Opens a session and registers its output/exit sinks before returning. */ async open( - params: { agentId?: string; cols: number; rows: number }, + params: { agentId?: string; cols: number; rows: number; catalog?: TerminalCatalogReference }, sink: SessionSink, ): Promise { const result = await this.requestWhileHoldingStream(() => diff --git a/ui/src/components/terminal/terminal-panel.test.ts b/ui/src/components/terminal/terminal-panel.test.ts index 18f94d7a79b9..4e7093d982d7 100644 --- a/ui/src/components/terminal/terminal-panel.test.ts +++ b/ui/src/components/terminal/terminal-panel.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest"; import { i18n } from "../../i18n/index.ts"; +import { createStorageMock } from "../../test-helpers/storage.ts"; import type { TerminalGatewayClient } from "./terminal-connection.ts"; type CreateOptions = { @@ -69,6 +70,8 @@ customElements.define(TERMINAL_PANEL_ELEMENT_NAME, TestTerminalPanel); describe("OpenClawTerminalPanel", () => { beforeEach(async () => { + vi.stubGlobal("localStorage", createStorageMock()); + vi.stubGlobal("sessionStorage", createStorageMock()); await i18n.setLocale("en"); }); @@ -77,6 +80,7 @@ describe("OpenClawTerminalPanel", () => { localStorage.clear(); sessionStorage.clear(); createGhosttyTerminalMock.mockReset(); + vi.unstubAllGlobals(); await i18n.setLocale("en"); }); @@ -156,6 +160,128 @@ describe("OpenClawTerminalPanel", () => { }); }); + it("opens a new titled tab for a catalog toggle request", async () => { + createGhosttyTerminalMock.mockImplementation(async () => createTerminalController()); + const requests: Array<{ method: string; params: unknown }> = []; + const client: TerminalGatewayClient = { + request: async (method: string, params?: unknown) => { + requests.push({ method, params }); + return { + ...terminalOpenResult("catalog-terminal-1"), + title: "codex resume 0d5c…", + } as T; + }, + addEventListener: () => () => {}, + }; + const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel; + panel.client = client; + panel.available = true; + document.body.append(panel); + const catalog = { catalogId: "codex", hostId: "node:mac", threadId: "thread" }; + + panel.handleToggleRequest(new CustomEvent("openclaw:terminal-toggle", { detail: { catalog } })); + + await vi.waitFor(() => { + expect(requests).toContainEqual({ + method: "terminal.open", + params: { agentId: undefined, cols: 100, rows: 30, catalog }, + }); + }); + await panel.updateComplete; + expect(panel.renderRoot.querySelector(".tp-tab")?.textContent).toContain("codex resume 0d5c…"); + }); + + it("reattaches persisted sessions before opening a catalog tab", async () => { + sessionStorage.setItem("openclaw.terminal.sessions.v1", JSON.stringify(["persisted-1"])); + createGhosttyTerminalMock + .mockResolvedValueOnce(createTerminalController()) + .mockResolvedValueOnce(createTerminalController()); + const requests: Array<{ method: string; params: unknown }> = []; + const client: TerminalGatewayClient = { + request: async (method: string, params?: unknown) => { + requests.push({ method, params }); + if (method === "terminal.list") { + return { + sessions: [ + { + ...terminalOpenResult("persisted-1"), + attached: false, + createdAtMs: 1, + }, + ], + } as T; + } + if (method === "terminal.attach") { + return { ...terminalOpenResult("persisted-1"), buffer: "persisted output" } as T; + } + if (method === "terminal.open") { + return { + ...terminalOpenResult("catalog-terminal-1"), + title: "codex resume thread", + } as T; + } + return {} as T; + }, + addEventListener: () => () => {}, + }; + const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel; + panel.client = client; + panel.available = true; + document.body.append(panel); + const catalog = { catalogId: "codex", hostId: "node:mac", threadId: "thread" }; + + panel.handleToggleRequest(new CustomEvent("openclaw:terminal-toggle", { detail: { catalog } })); + + await vi.waitFor(() => { + expect(requests.filter((entry) => entry.method === "terminal.attach")).toHaveLength(1); + expect(requests.filter((entry) => entry.method === "terminal.open")).toHaveLength(1); + }); + expect(requests.findIndex((entry) => entry.method === "terminal.attach")).toBeLessThan( + requests.findIndex((entry) => entry.method === "terminal.open"), + ); + expect(sessionStorage.getItem("openclaw.terminal.sessions.v1")).toBe( + JSON.stringify(["persisted-1", "catalog-terminal-1"]), + ); + }); + + it("queues a catalog toggle that arrives during another terminal boot", async () => { + const firstBoot = deferred>(); + createGhosttyTerminalMock + .mockReturnValueOnce(firstBoot.promise) + .mockResolvedValueOnce(createTerminalController()); + const requests: Array<{ method: string; params: unknown }> = []; + let openCount = 0; + const client: TerminalGatewayClient = { + request: async (method: string, params?: unknown) => { + requests.push({ method, params }); + if (method === "terminal.open") { + openCount += 1; + return terminalOpenResult(`session-${openCount}`) as T; + } + return {} as T; + }, + addEventListener: () => () => {}, + }; + const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel; + panel.client = client; + panel.available = true; + document.body.append(panel); + panel.toggle(); + await vi.waitFor(() => expect(createGhosttyTerminalMock).toHaveBeenCalledOnce()); + const catalog = { catalogId: "codex", hostId: "node:mac", threadId: "thread" }; + + panel.handleToggleRequest(new CustomEvent("openclaw:terminal-toggle", { detail: { catalog } })); + firstBoot.resolve(createTerminalController()); + + await vi.waitFor(() => { + expect(requests).toContainEqual({ + method: "terminal.open", + params: { agentId: undefined, cols: 100, rows: 30, catalog }, + }); + }); + expect(requests.filter((entry) => entry.method === "terminal.open")).toHaveLength(2); + }); + it("fullscreen mode auto-opens without dock chrome and survives last-tab close", async () => { createGhosttyTerminalMock.mockImplementation(async () => createTerminalController()); const requests: Array<{ method: string; params: unknown }> = []; @@ -343,11 +469,15 @@ describe("OpenClawTerminalPanel", () => { panel.remove(); document.body.append(panel); + await panel.updateComplete; + expect(createGhosttyTerminalMock).toHaveBeenCalledOnce(); + expect(requests.filter((method) => method === "terminal.open")).toHaveLength(0); + staleBoot.resolve(staleController); + await vi.waitFor(() => { expect(createGhosttyTerminalMock).toHaveBeenCalledTimes(2); expect(requests.filter((method) => method === "terminal.open")).toHaveLength(1); }); - staleBoot.resolve(staleController); await vi.waitFor(() => { expect(staleController.dispose).toHaveBeenCalledOnce(); diff --git a/ui/src/components/terminal/terminal-panel.ts b/ui/src/components/terminal/terminal-panel.ts index d1bfa21cad6f..428f8a248319 100644 --- a/ui/src/components/terminal/terminal-panel.ts +++ b/ui/src/components/terminal/terminal-panel.ts @@ -18,6 +18,11 @@ import { import { TerminalConnection, type TerminalGatewayClient } from "./terminal-connection.ts"; import { renderTerminalPanelTabs, type TerminalPanelTab } from "./terminal-panel-tabs.ts"; import { createIsolatedGhosttyTerminal } from "./terminal-runtime.ts"; +import { + loadPersistedTerminalSessionIds, + persistTerminalSessionIds, +} from "./terminal-session-storage.ts"; +import { TerminalTaskQueue } from "./terminal-task-queue.ts"; import { terminalTheme } from "./terminal-theme.ts"; // Inline icon set (self-contained; the Control UI blocks external asset loads). @@ -54,32 +59,11 @@ const panelLayout = createDockPanelLayout({ defaultHeight: 320, defaultWidth: 520, }); -// Session ids for reattach after a reload/reconnect. Deliberately -// sessionStorage, not localStorage: attach is take-over, and a shared -// per-origin key would make multiple Control UI windows clobber each other's -// ids and steal each other's live shells. Per-tab storage survives exactly the -// cases reattach is for (reload, laptop sleep, transient disconnect). -const SESSIONS_KEY = "openclaw.terminal.sessions.v1"; const TERMINAL_FONT_FAMILY = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Symbols Nerd Font Mono", "MesloLGLDZ Nerd Font Mono", "JetBrainsMono Nerd Font Mono", "Liberation Mono", monospace'; const TERMINAL_INPUT_DECODER = new TextDecoder(); const TERMINAL_OUTPUT_ENCODER = new TextEncoder(); -function loadPersistedSessionIds(): string[] { - try { - const raw = globalThis.sessionStorage?.getItem(SESSIONS_KEY); - if (!raw) { - return []; - } - const parsed: unknown = JSON.parse(raw); - return Array.isArray(parsed) - ? parsed.filter((id): id is string => typeof id === "string" && id.length > 0) - : []; - } catch { - return []; - } -} - /** `` — the dockable Control UI shell surface. */ export class OpenClawTerminalPanel extends OpenClawLitElement { /** Gateway client used for terminal.* RPCs; null until connected. */ @@ -113,6 +97,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { private lifecycleSyncToken = 0; private resizeCleanup: (() => void) | null = null; private tabSeq = 0; + private readonly bootQueue = new TerminalTaskQueue(); protected createTerminal = createIsolatedGhosttyTerminal; private readonly onGlobalKeyDown = (event: KeyboardEvent) => this.handleGlobalKey(event); private readonly onToggleRequest = (event: Event) => this.handleToggleRequest(event); @@ -300,14 +285,14 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { if (dock) { this.dock = dock; } - if (detail?.open === true) { + if (detail?.catalog || detail?.open === true) { if (!this.available) { return; } this.open = true; this.syncLayoutReservation(); this.persistLayout(); - void this.restoreSessions(); + void (detail.catalog ? this.openCatalogSession(detail.catalog) : this.restoreSessions()); return; } this.toggle(); @@ -332,11 +317,25 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { * the gateway still has them, otherwise fall back to one fresh session. */ private async restoreSessions(): Promise { + await this.bootQueue.enqueueSteps( + () => this.reattachPersistedSessions(), + () => this.ensureInitialSession(), + ); + } + + private async openCatalogSession(catalog: NonNullable) { + await this.bootQueue.enqueueSteps( + () => this.reattachPersistedSessions(), + () => this.openSessionNow(catalog), + ); + } + + private async reattachPersistedSessions(): Promise { const operation = this.captureTerminalOperation(); - if (!operation || this.booting || this.tabs.length > 0) { + if (!operation || this.tabs.length > 0) { return; } - const persisted = loadPersistedSessionIds(); + const persisted = loadPersistedTerminalSessionIds(); if (persisted.length > 0) { this.booting = true; try { @@ -369,12 +368,11 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { // Prune ids the gateway no longer knows (reaped or externally closed). this.persistLiveSessions(); } - await this.ensureInitialSession(); } private async ensureInitialSession(): Promise { if (this.tabs.length === 0 && !this.booting) { - await this.openSession(); + await this.openSessionNow(); } } @@ -477,10 +475,10 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { /** Binds a freshly opened or attached gateway session to its tab. */ private adoptSession( tab: TerminalTabState, - result: { sessionId: string; shell: string; agentId: string; cwd: string }, + result: { sessionId: string; shell: string; agentId: string; cwd: string; title?: string }, ): void { tab.gatewaySessionId = result.sessionId; - tab.shellName = shellBasename(result.shell); + tab.shellName = result.title ?? shellBasename(result.shell); tab.agentId = result.agentId; tab.cwd = result.cwd; // Libterminal observes layout before the Gateway session exists. Resync the @@ -501,9 +499,13 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { } } - private async openSession(): Promise { + private async openSession(catalog?: TerminalPanelToggleDetail["catalog"]): Promise { + await this.bootQueue.enqueue(() => this.openSessionNow(catalog)); + } + + private async openSessionNow(catalog?: TerminalPanelToggleDetail["catalog"]): Promise { const operation = this.captureTerminalOperation(); - if (!operation || this.booting) { + if (!operation) { return; } this.booting = true; @@ -516,7 +518,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { const boot = await this.bootTab(operation); createdTab = boot.tab; const result = await boot.connection.open( - { agentId, cols: boot.cols, rows: boot.rows }, + { agentId, cols: boot.cols, rows: boot.rows, ...(catalog ? { catalog } : {}) }, this.tabSink(boot.tab), ); if (!this.isTerminalOperationCurrent(operation) || boot.tab.cancelled) { @@ -679,6 +681,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { this.lifecycleGeneration += 1; this.lifecycleAbortController.abort(); this.lifecycleAbortController = new AbortController(); + this.bootQueue.reset(); this.booting = false; this.clearResizeListeners(); for (const tab of this.tabs) { @@ -721,11 +724,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { const ids = this.tabs .filter((tab) => tab.status === "live" && tab.gatewaySessionId) .map((tab) => tab.gatewaySessionId); - try { - globalThis.sessionStorage?.setItem(SESSIONS_KEY, JSON.stringify(ids)); - } catch { - // Storage may be unavailable (private mode); reattach just won't work. - } + persistTerminalSessionIds(ids); } private persistLayout(): void { diff --git a/ui/src/components/terminal/terminal-session-storage.ts b/ui/src/components/terminal/terminal-session-storage.ts new file mode 100644 index 000000000000..016986fc82bb --- /dev/null +++ b/ui/src/components/terminal/terminal-session-storage.ts @@ -0,0 +1,27 @@ +// Session ids deliberately use per-tab storage: attach is a takeover, so shared +// local storage could let one Control UI window steal another window's shells. + +const TERMINAL_SESSIONS_KEY = "openclaw.terminal.sessions.v1"; + +export function loadPersistedTerminalSessionIds(): string[] { + try { + const raw = globalThis.sessionStorage?.getItem(TERMINAL_SESSIONS_KEY); + if (!raw) { + return []; + } + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.filter((id): id is string => typeof id === "string" && id.length > 0) + : []; + } catch { + return []; + } +} + +export function persistTerminalSessionIds(ids: readonly string[]): void { + try { + globalThis.sessionStorage?.setItem(TERMINAL_SESSIONS_KEY, JSON.stringify(ids)); + } catch { + // Storage may be unavailable (private mode); reattach just won't work. + } +} diff --git a/ui/src/components/terminal/terminal-task-queue.test.ts b/ui/src/components/terminal/terminal-task-queue.test.ts new file mode 100644 index 000000000000..66c2903b0861 --- /dev/null +++ b/ui/src/components/terminal/terminal-task-queue.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { TerminalTaskQueue } from "./terminal-task-queue.ts"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +describe("TerminalTaskQueue", () => { + it("drains superseded work before starting the next generation", async () => { + const queue = new TerminalTaskQueue(); + const release = deferred(); + const events: string[] = []; + const stale = queue.enqueue(async (isCurrent) => { + events.push("stale:start"); + await release.promise; + events.push(isCurrent() ? "stale:current" : "stale:cancelled"); + }); + await Promise.resolve(); + + queue.reset(); + const current = queue.enqueue((isCurrent) => { + events.push(isCurrent() ? "current:start" : "current:cancelled"); + return Promise.resolve(); + }); + await Promise.resolve(); + expect(events).toEqual(["stale:start"]); + + release.resolve(); + await Promise.all([stale, current]); + expect(events).toEqual(["stale:start", "stale:cancelled", "current:start"]); + }); +}); diff --git a/ui/src/components/terminal/terminal-task-queue.ts b/ui/src/components/terminal/terminal-task-queue.ts new file mode 100644 index 000000000000..910cd6816180 --- /dev/null +++ b/ui/src/components/terminal/terminal-task-queue.ts @@ -0,0 +1,35 @@ +// Terminal boot ownership: serialize open and reattach work so requests that +// arrive during an async boot are delayed instead of silently discarded. +async function runTerminalTaskSteps( + isCurrent: () => boolean, + steps: Array<() => Promise>, +): Promise { + for (const step of steps) { + await step(); + if (!isCurrent()) { + return; + } + } +} + +export class TerminalTaskQueue { + private tail: Promise = Promise.resolve(); + private generation = 0; + + enqueue(task: (isCurrent: () => boolean) => Promise): Promise { + const generation = this.generation; + const isCurrent = () => generation === this.generation; + const run = () => (isCurrent() ? task(isCurrent) : Promise.resolve()); + const next = this.tail.then(run, run); + this.tail = next.catch(() => {}); + return next; + } + + enqueueSteps(...steps: Array<() => Promise>): Promise { + return this.enqueue((isCurrent) => runTerminalTaskSteps(isCurrent, steps)); + } + + reset(): void { + this.generation += 1; + } +} diff --git a/ui/src/i18n/.i18n/ar.meta.json b/ui/src/i18n/.i18n/ar.meta.json index f9b5b36f657f..b1d23e64de13 100644 --- a/ui/src/i18n/.i18n/ar.meta.json +++ b/ui/src/i18n/.i18n/ar.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:54:00.093Z", + "generatedAt": "2026-07-14T05:17:23.793Z", "locale": "ar", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/de.meta.json b/ui/src/i18n/.i18n/de.meta.json index 93e2838193db..95bad642497f 100644 --- a/ui/src/i18n/.i18n/de.meta.json +++ b/ui/src/i18n/.i18n/de.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:53:11.297Z", + "generatedAt": "2026-07-14T05:17:22.410Z", "locale": "de", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/es.meta.json b/ui/src/i18n/.i18n/es.meta.json index 1725e056fbb1..fe57812fe583 100644 --- a/ui/src/i18n/.i18n/es.meta.json +++ b/ui/src/i18n/.i18n/es.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:53:16.889Z", + "generatedAt": "2026-07-14T05:17:22.632Z", "locale": "es", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fa.meta.json b/ui/src/i18n/.i18n/fa.meta.json index 9dbf9c5dd58b..e39e49528137 100644 --- a/ui/src/i18n/.i18n/fa.meta.json +++ b/ui/src/i18n/.i18n/fa.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:55:20.722Z", + "generatedAt": "2026-07-14T05:17:26.027Z", "locale": "fa", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/fr.meta.json b/ui/src/i18n/.i18n/fr.meta.json index 086b44eb7f98..5a01bdb0968a 100644 --- a/ui/src/i18n/.i18n/fr.meta.json +++ b/ui/src/i18n/.i18n/fr.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:53:41.406Z", + "generatedAt": "2026-07-14T05:17:23.308Z", "locale": "fr", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/hi.meta.json b/ui/src/i18n/.i18n/hi.meta.json index eac8d225c599..4b5863cf35c9 100644 --- a/ui/src/i18n/.i18n/hi.meta.json +++ b/ui/src/i18n/.i18n/hi.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:53:53.592Z", + "generatedAt": "2026-07-14T05:17:23.547Z", "locale": "hi", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/hi.tm.jsonl b/ui/src/i18n/.i18n/hi.tm.jsonl index 8f2a5826af61..d8963b93e3e0 100644 --- a/ui/src/i18n/.i18n/hi.tm.jsonl +++ b/ui/src/i18n/.i18n/hi.tm.jsonl @@ -263,6 +263,7 @@ {"cache_key":"1440f57edb258f75c86d35c9edaca337b0db54f95d3d30ed7ba7ce60474ea30f","model":"gpt-5.5","provider":"openai","segment_id":"workboard.agentFilter","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Filter by agent","text_hash":"94dc2569edb014636216936280f2287350bad6aabf16dbb026bbcac567027550","tgt_lang":"hi","translated":"एजेंट के अनुसार फ़िल्टर करें","updated_at":"2026-06-26T21:32:18.718Z"} {"cache_key":"14649a21c099ef95c7d07f6aab6222e5e0903b030f092820c1bd5274132377c4","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.detailPanel.nextMatch","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Next match","text_hash":"825e5abd0762be6e7083ca449c61fc73862d150d429cc9351b8cfae2a05cecc8","tgt_lang":"hi","translated":"अगला मिलान","updated_at":"2026-07-12T06:43:01.834Z"} {"cache_key":"146bea1e6d806555e8add89e60668fb17ca007ec1a000069dc0d02c54792c55e","model":"gpt-5.5","provider":"openai","segment_id":"cron.form.webhookUrl","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Webhook URL","text_hash":"84805a7574a82052bdd5b3b98119cfd838d04036ec4bd3d667a95698e7097ad6","tgt_lang":"hi","translated":"Webhook URL","updated_at":"2026-06-26T21:37:57.526Z"} +{"cache_key":"14838c7a335d257641b3caca37872822a99e41b3c5269c823ddffa85e6403d12","model":"gpt-5.5","provider":"openai","segment_id":"chat.catalogOpenTargetTerminal","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Terminal","text_hash":"e0926fdac700b09497b5f0218ea3dd54fa13c0bdeaee6caa7b85e50b852aa05f","tgt_lang":"hi","translated":"टर्मिनल","updated_at":"2026-06-26T21:33:40.484Z"} {"cache_key":"1486ef91145bc01bba3c7fa6cee8b038232c867a0478cbb841990127a7da31b2","model":"gpt-5.5","provider":"openai","segment_id":"usage.details.conversation","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Conversation","text_hash":"ccca1817575365871461752f3229dd59ede742ae69e350e20fd00a6ce3d149e3","tgt_lang":"hi","translated":"बातचीत","updated_at":"2026-06-26T21:35:26.182Z"} {"cache_key":"14cc9cb1d9a90015b67a9421d15477488260b17ff0d9de07df8a003ca7f0d958","model":"gpt-5.5","provider":"openai","segment_id":"cron.jobs.schedule","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Schedule","text_hash":"f4830a1dae2980447c716bd4b5779b7013575ef09f70ef4731457218792487b3","tgt_lang":"hi","translated":"शेड्यूल","updated_at":"2026-06-26T21:37:06.965Z"} {"cache_key":"14ccee0722198fc3d3ea65ce5db46481716eea46e029af116d2c5507c9ff2d73","model":"gpt-5.5","provider":"openai","segment_id":"claudeSessions.transcript.item","source_path":"ui/src/i18n/locales/hi.ts","src_lang":"en","text":"Claude item","text_hash":"325d97a33be5a63ad70e4e8d6113714b8f8d8472e337db0ba459abf85b1b1d48","tgt_lang":"hi","translated":"Claude आइटम","updated_at":"2026-07-11T13:50:48.473Z"} diff --git a/ui/src/i18n/.i18n/id.meta.json b/ui/src/i18n/.i18n/id.meta.json index eb6e6e582562..cafc25dc354a 100644 --- a/ui/src/i18n/.i18n/id.meta.json +++ b/ui/src/i18n/.i18n/id.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:54:35.208Z", + "generatedAt": "2026-07-14T05:17:24.718Z", "locale": "id", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/it.meta.json b/ui/src/i18n/.i18n/it.meta.json index d976eb264fac..cd407736a7bb 100644 --- a/ui/src/i18n/.i18n/it.meta.json +++ b/ui/src/i18n/.i18n/it.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:54:09.261Z", + "generatedAt": "2026-07-14T05:17:24.028Z", "locale": "it", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ja-JP.meta.json b/ui/src/i18n/.i18n/ja-JP.meta.json index f53d2e793c1d..e99d81f7d1eb 100644 --- a/ui/src/i18n/.i18n/ja-JP.meta.json +++ b/ui/src/i18n/.i18n/ja-JP.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:53:23.957Z", + "generatedAt": "2026-07-14T05:17:22.856Z", "locale": "ja-JP", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ko.meta.json b/ui/src/i18n/.i18n/ko.meta.json index 24239b9afffa..82e8bf537727 100644 --- a/ui/src/i18n/.i18n/ko.meta.json +++ b/ui/src/i18n/.i18n/ko.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:53:32.458Z", + "generatedAt": "2026-07-14T05:17:23.079Z", "locale": "ko", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/nl.meta.json b/ui/src/i18n/.i18n/nl.meta.json index 5beefe29f159..0f4f2f71f085 100644 --- a/ui/src/i18n/.i18n/nl.meta.json +++ b/ui/src/i18n/.i18n/nl.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:55:12.441Z", + "generatedAt": "2026-07-14T05:17:25.782Z", "locale": "nl", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pl.meta.json b/ui/src/i18n/.i18n/pl.meta.json index 3230dc363a8b..a8c3f6f48d01 100644 --- a/ui/src/i18n/.i18n/pl.meta.json +++ b/ui/src/i18n/.i18n/pl.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:54:44.377Z", + "generatedAt": "2026-07-14T05:17:24.991Z", "locale": "pl", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/pt-BR.meta.json b/ui/src/i18n/.i18n/pt-BR.meta.json index 97dc51d492e1..fa191db704ec 100644 --- a/ui/src/i18n/.i18n/pt-BR.meta.json +++ b/ui/src/i18n/.i18n/pt-BR.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:53:03.154Z", + "generatedAt": "2026-07-14T05:17:22.215Z", "locale": "pt-BR", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ru.meta.json b/ui/src/i18n/.i18n/ru.meta.json index bbd7e89ef539..4aa3ad1c55e6 100644 --- a/ui/src/i18n/.i18n/ru.meta.json +++ b/ui/src/i18n/.i18n/ru.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:55:30.458Z", + "generatedAt": "2026-07-14T05:17:26.260Z", "locale": "ru", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/ru.tm.jsonl b/ui/src/i18n/.i18n/ru.tm.jsonl index 9d49af57a185..4c42233c75ce 100644 --- a/ui/src/i18n/.i18n/ru.tm.jsonl +++ b/ui/src/i18n/.i18n/ru.tm.jsonl @@ -2495,6 +2495,7 @@ {"cache_key":"ca9e7dd810964197f78d3594df698f1ebb9c7a65ed1c3df3deebc21a53d36290","model":"gpt-5.5","provider":"openai","segment_id":"sessionsView.actions","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Actions","text_hash":"ff8059dc6752afdd30d275932b1d5031a2ec854b387a8c57ecc6689915293a43","tgt_lang":"ru","translated":"Действия","updated_at":"2026-06-26T21:38:54.164Z"} {"cache_key":"cab9cefcce731f548ef4fbac183fc70ec75e11948009a712deaf3ab3bd8c4a3d","model":"gpt-5.5","provider":"openai","segment_id":"workspaces.widget.menu.remove","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Remove","text_hash":"c3812fc4acb861d5182fc2b8155f327f736fbe5e5eb86a7bd7afcb6dc5497282","tgt_lang":"ru","translated":"Удалить","updated_at":"2026-06-26T21:42:47.554Z"} {"cache_key":"cac3b04be4eb40dd77377570c608c524c3b58fc0ffa3991763a83862efb918c6","model":"gpt-5.5","provider":"openai","segment_id":"usage.overview.messages","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Messages","text_hash":"04d7b48339271ea67d3c8493e07e90bc68dc565485eebe5e0b67c21c1586e3c0","tgt_lang":"ru","translated":"Сообщения","updated_at":"2026-06-26T21:41:12.411Z"} +{"cache_key":"cac9af08a91770803214a2914631d09cd907471c9ba48485cb40fbe77c954dc6","model":"gpt-5.5","provider":"openai","segment_id":"chat.catalogOpenTargetTerminal","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Terminal","text_hash":"e0926fdac700b09497b5f0218ea3dd54fa13c0bdeaee6caa7b85e50b852aa05f","tgt_lang":"ru","translated":"Терминал","updated_at":"2026-06-26T21:40:31.548Z"} {"cache_key":"cadb7277535ca8a7ff446d92159b60566452966ccdbe7bd3cd48dadc3ee72ea4","model":"gpt-5.5","provider":"openai","segment_id":"instances.hideHosts","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Hide hosts and IPs","text_hash":"89fb72b6105a014b77e71fac6fe4d6b492e4804db99e32e7c90ac1aa0c333a81","tgt_lang":"ru","translated":"Скрыть хосты и IP-адреса","updated_at":"2026-06-26T21:38:48.084Z"} {"cache_key":"cadda655c1f8162358e6e19aecc2da139693e64f97ca59f1d4bbc3e4641b69b7","model":"claude-opus-4-8","provider":"anthropic","segment_id":"chat.sideChat.thinking","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Thinking…","text_hash":"a02f1cea3c1d6c6edc0bdf52b8807f09eb5b33ca35a5e938e49d9ff646f80969","tgt_lang":"ru","translated":"Думаю…","updated_at":"2026-07-12T07:02:38.378Z"} {"cache_key":"cb063742aeb28e12e138da1a0ff21d295c74b3e76157ae9159d5b9f904cd0cb4","model":"gpt-5.6-sol","provider":"openai","segment_id":"modelProviders.notes.defaultModel","source_path":"ui/src/i18n/locales/ru.ts","src_lang":"en","text":"Update default model selection from Control UI","text_hash":"40174ecf454540e7b792a679bccd7646756917788d176c1effc27de95c36c948","tgt_lang":"ru","translated":"Обновить выбор модели по умолчанию из Control UI","updated_at":"2026-07-13T16:33:43.103Z"} diff --git a/ui/src/i18n/.i18n/th.meta.json b/ui/src/i18n/.i18n/th.meta.json index b78acb0209f2..4e9633885062 100644 --- a/ui/src/i18n/.i18n/th.meta.json +++ b/ui/src/i18n/.i18n/th.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:54:51.816Z", + "generatedAt": "2026-07-14T05:17:25.263Z", "locale": "th", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/tr.meta.json b/ui/src/i18n/.i18n/tr.meta.json index 1384d4010224..9de0d16ee7fc 100644 --- a/ui/src/i18n/.i18n/tr.meta.json +++ b/ui/src/i18n/.i18n/tr.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:54:15.564Z", + "generatedAt": "2026-07-14T05:17:24.234Z", "locale": "tr", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/uk.meta.json b/ui/src/i18n/.i18n/uk.meta.json index 59c346e84dfe..c1ebeb19da00 100644 --- a/ui/src/i18n/.i18n/uk.meta.json +++ b/ui/src/i18n/.i18n/uk.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:54:29.080Z", + "generatedAt": "2026-07-14T05:17:24.475Z", "locale": "uk", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/vi.meta.json b/ui/src/i18n/.i18n/vi.meta.json index 7224f6bad93c..f62cd4c2d2a1 100644 --- a/ui/src/i18n/.i18n/vi.meta.json +++ b/ui/src/i18n/.i18n/vi.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:55:03.180Z", + "generatedAt": "2026-07-14T05:17:25.528Z", "locale": "vi", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-CN.meta.json b/ui/src/i18n/.i18n/zh-CN.meta.json index 2afa2fe7690d..5ca67e027ff5 100644 --- a/ui/src/i18n/.i18n/zh-CN.meta.json +++ b/ui/src/i18n/.i18n/zh-CN.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:52:48.651Z", + "generatedAt": "2026-07-14T05:17:21.655Z", "locale": "zh-CN", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/.i18n/zh-TW.meta.json b/ui/src/i18n/.i18n/zh-TW.meta.json index f22b9ca61bf0..fac70fc409a6 100644 --- a/ui/src/i18n/.i18n/zh-TW.meta.json +++ b/ui/src/i18n/.i18n/zh-TW.meta.json @@ -1,11 +1,11 @@ { "fallbackKeys": [], - "generatedAt": "2026-07-14T04:52:54.602Z", + "generatedAt": "2026-07-14T05:17:22.023Z", "locale": "zh-TW", "model": "gpt-5.6-sol", "provider": "openai", - "sourceHash": "0a0b0ec4127880a41c3661fdc24cfba4429833914daf8afc6366bf7bdf626020", - "totalKeys": 3325, - "translatedKeys": 3325, + "sourceHash": "622f10085ef94b6f07d88f35cf370b2ce3108dbb36897da543cfc3b82ccd2240", + "totalKeys": 3332, + "translatedKeys": 3332, "workflow": 1 } diff --git a/ui/src/i18n/locales/ar.ts b/ui/src/i18n/locales/ar.ts index a6b67e5d09cd..91412c73df35 100644 --- a/ui/src/i18n/locales/ar.ts +++ b/ui/src/i18n/locales/ar.ts @@ -3284,6 +3284,10 @@ export const ar: TranslationMap = { catalog: { remoteViewOnly: "هذه الجلسة موجودة على عقدة مقترنة وهي للعرض فقط.", unsupportedViewOnly: "مصدر الجلسة الخارجية هذا للعرض فقط.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "متابعة مقترحة", @@ -3327,6 +3331,9 @@ export const ar: TranslationMap = { sendShortcut: "اختصار الإرسال", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "معطل أثناء الإعداد", gatewayStatus: "حالة Gateway: {status}", commandPaletteTitle: "ابحث أو انتقل إلى… (⌘K)", diff --git a/ui/src/i18n/locales/de.ts b/ui/src/i18n/locales/de.ts index b20589de321a..d7575d9364cb 100644 --- a/ui/src/i18n/locales/de.ts +++ b/ui/src/i18n/locales/de.ts @@ -3355,6 +3355,10 @@ export const de: TranslationMap = { remoteViewOnly: "Diese Sitzung befindet sich auf einem gekoppelten Knoten und ist schreibgeschützt.", unsupportedViewOnly: "Diese externe Sitzungsquelle ist schreibgeschützt.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "Vorgeschlagene Folgeaufgabe", @@ -3399,6 +3403,9 @@ export const de: TranslationMap = { sendShortcut: "Tastenkürzel zum Senden", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "Während der Einrichtung deaktiviert", gatewayStatus: "Gateway-Status: {status}", commandPaletteTitle: "Suchen oder springen zu… (⌘K)", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index cb3d7c9b5bd9..0c7e48dbd2e0 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -3263,6 +3263,10 @@ export const en: TranslationMap = { catalog: { remoteViewOnly: "This session is on a paired node and is view-only.", unsupportedViewOnly: "This external session source is view-only.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "Suggested follow-up", @@ -3306,6 +3310,9 @@ export const en: TranslationMap = { sendShortcut: "Send shortcut", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "Disabled during setup", gatewayStatus: "Gateway status: {status}", commandPaletteTitle: "Search or jump to… (⌘K)", diff --git a/ui/src/i18n/locales/es.ts b/ui/src/i18n/locales/es.ts index 52a7867c140a..8ff079b8f01a 100644 --- a/ui/src/i18n/locales/es.ts +++ b/ui/src/i18n/locales/es.ts @@ -3350,6 +3350,10 @@ export const es: TranslationMap = { catalog: { remoteViewOnly: "Esta sesión está en un nodo emparejado y es de solo lectura.", unsupportedViewOnly: "Esta fuente de sesión externa es de solo lectura.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "Tarea de seguimiento sugerida", @@ -3394,6 +3398,9 @@ export const es: TranslationMap = { sendShortcut: "Atajo de envío", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "Deshabilitado durante el inicio guiado", gatewayStatus: "Estado del Gateway: {status}", commandPaletteTitle: "Buscar o ir a… (⌘K)", diff --git a/ui/src/i18n/locales/fa.ts b/ui/src/i18n/locales/fa.ts index 46a2f2a84212..b38c902812cd 100644 --- a/ui/src/i18n/locales/fa.ts +++ b/ui/src/i18n/locales/fa.ts @@ -3312,6 +3312,10 @@ export const fa: TranslationMap = { catalog: { remoteViewOnly: "این نشست روی یک گره جفت‌شده قرار دارد و فقط قابل مشاهده است.", unsupportedViewOnly: "این منبع نشست خارجی فقط قابل مشاهده است.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "کار پیگیری پیشنهادی", @@ -3355,6 +3359,9 @@ export const fa: TranslationMap = { sendShortcut: "میان‌بر ارسال", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "در طول راه‌اندازی غیرفعال است", gatewayStatus: "وضعیت Gateway: {status}", commandPaletteTitle: "جست‌وجو یا رفتن به… (⌘K)", diff --git a/ui/src/i18n/locales/fr.ts b/ui/src/i18n/locales/fr.ts index e227aa4bcb68..d3a7fb6ca279 100644 --- a/ui/src/i18n/locales/fr.ts +++ b/ui/src/i18n/locales/fr.ts @@ -3379,6 +3379,10 @@ export const fr: TranslationMap = { catalog: { remoteViewOnly: "Cette session se trouve sur un nœud associé et est en lecture seule.", unsupportedViewOnly: "Cette source de session externe est en lecture seule.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "Suivi suggéré", @@ -3423,6 +3427,9 @@ export const fr: TranslationMap = { sendShortcut: "Raccourci d’envoi", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "Désactivé pendant la configuration", gatewayStatus: "État du Gateway : {status}", commandPaletteTitle: "Rechercher ou accéder à… (⌘K)", diff --git a/ui/src/i18n/locales/hi.ts b/ui/src/i18n/locales/hi.ts index f517c87a0d1a..1f123484c0a8 100644 --- a/ui/src/i18n/locales/hi.ts +++ b/ui/src/i18n/locales/hi.ts @@ -3280,6 +3280,10 @@ export const hi: TranslationMap = { catalog: { remoteViewOnly: "यह सत्र एक युग्मित नोड पर है और केवल देखने के लिए है।", unsupportedViewOnly: "यह बाहरी सत्र स्रोत केवल देखने के लिए है।", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "सुझाया गया अगला कार्य", @@ -3323,6 +3327,9 @@ export const hi: TranslationMap = { sendShortcut: "भेजने का शॉर्टकट", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "टर्मिनल", onboardingDisabled: "सेटअप के दौरान अक्षम", gatewayStatus: "Gateway स्थिति: {status}", commandPaletteTitle: "खोजें या यहां जाएं… (⌘K)", diff --git a/ui/src/i18n/locales/id.ts b/ui/src/i18n/locales/id.ts index 797cd953018c..826aad6f6d19 100644 --- a/ui/src/i18n/locales/id.ts +++ b/ui/src/i18n/locales/id.ts @@ -3317,6 +3317,10 @@ export const id: TranslationMap = { catalog: { remoteViewOnly: "Sesi ini berada di node yang dipasangkan dan hanya dapat dilihat.", unsupportedViewOnly: "Sumber sesi eksternal ini hanya dapat dilihat.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "Tindak lanjut yang disarankan", @@ -3360,6 +3364,9 @@ export const id: TranslationMap = { sendShortcut: "Pintasan kirim", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "Dinonaktifkan selama penyiapan", gatewayStatus: "Status Gateway: {status}", commandPaletteTitle: "Cari atau lompat ke… (⌘K)", diff --git a/ui/src/i18n/locales/it.ts b/ui/src/i18n/locales/it.ts index ab50a3a2fca6..df988e00ef40 100644 --- a/ui/src/i18n/locales/it.ts +++ b/ui/src/i18n/locales/it.ts @@ -3354,6 +3354,10 @@ export const it: TranslationMap = { catalog: { remoteViewOnly: "Questa sessione si trova su un nodo associato ed è di sola visualizzazione.", unsupportedViewOnly: "Questa origine di sessione esterna è di sola visualizzazione.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "Attività successiva suggerita", @@ -3398,6 +3402,9 @@ export const it: TranslationMap = { sendShortcut: "Scorciatoia di invio", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "Disabilitato durante la configurazione", gatewayStatus: "Stato Gateway: {status}", commandPaletteTitle: "Cerca o passa a… (⌘K)", diff --git a/ui/src/i18n/locales/ja-JP.ts b/ui/src/i18n/locales/ja-JP.ts index e6ef3d25c5a4..5199d1706b55 100644 --- a/ui/src/i18n/locales/ja-JP.ts +++ b/ui/src/i18n/locales/ja-JP.ts @@ -3329,6 +3329,10 @@ export const ja_JP: TranslationMap = { catalog: { remoteViewOnly: "このセッションはペアリング済みノード上にあり、表示専用です。", unsupportedViewOnly: "この外部セッションソースは表示専用です。", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "提案されたフォローアップ", @@ -3372,6 +3376,9 @@ export const ja_JP: TranslationMap = { sendShortcut: "送信ショートカット", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "セットアップ中は無効", gatewayStatus: "Gateway ステータス: {status}", commandPaletteTitle: "検索または移動… (⌘K)", diff --git a/ui/src/i18n/locales/ko.ts b/ui/src/i18n/locales/ko.ts index c6cfde5e608d..7ea42f51d12a 100644 --- a/ui/src/i18n/locales/ko.ts +++ b/ui/src/i18n/locales/ko.ts @@ -3297,6 +3297,10 @@ export const ko: TranslationMap = { catalog: { remoteViewOnly: "이 세션은 페어링된 노드에 있으며 보기 전용입니다.", unsupportedViewOnly: "이 외부 세션 소스는 보기 전용입니다.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "제안된 후속 작업", @@ -3340,6 +3344,9 @@ export const ko: TranslationMap = { sendShortcut: "전송 단축키", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "설정 중에는 비활성화됨", gatewayStatus: "Gateway 상태: {status}", commandPaletteTitle: "검색하거나 이동… (⌘K)", diff --git a/ui/src/i18n/locales/nl.ts b/ui/src/i18n/locales/nl.ts index 0d36f25cf6f0..d14ddf4f6995 100644 --- a/ui/src/i18n/locales/nl.ts +++ b/ui/src/i18n/locales/nl.ts @@ -3332,6 +3332,10 @@ export const nl: TranslationMap = { catalog: { remoteViewOnly: "Deze sessie bevindt zich op een gekoppeld knooppunt en is alleen-lezen.", unsupportedViewOnly: "Deze externe sessiebron is alleen-lezen.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "Voorgestelde vervolgtaak", @@ -3375,6 +3379,9 @@ export const nl: TranslationMap = { sendShortcut: "Sneltoets voor verzenden", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "Uitgeschakeld tijdens configuratie", gatewayStatus: "Gateway-status: {status}", commandPaletteTitle: "Zoeken of springen naar… (⌘K)", diff --git a/ui/src/i18n/locales/pl.ts b/ui/src/i18n/locales/pl.ts index cb532acb5530..b0b8330736ed 100644 --- a/ui/src/i18n/locales/pl.ts +++ b/ui/src/i18n/locales/pl.ts @@ -3340,6 +3340,10 @@ export const pl: TranslationMap = { catalog: { remoteViewOnly: "Ta sesja znajduje się na sparowanym węźle i jest tylko do odczytu.", unsupportedViewOnly: "To zewnętrzne źródło sesji jest tylko do odczytu.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "Sugerowane zadanie uzupełniające", @@ -3384,6 +3388,9 @@ export const pl: TranslationMap = { sendShortcut: "Skrót wysyłania", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "Wyłączone podczas konfiguracji", gatewayStatus: "Status Gateway: {status}", commandPaletteTitle: "Wyszukaj lub przejdź do… (⌘K)", diff --git a/ui/src/i18n/locales/pt-BR.ts b/ui/src/i18n/locales/pt-BR.ts index 236ce099bc6a..1618eb915d4c 100644 --- a/ui/src/i18n/locales/pt-BR.ts +++ b/ui/src/i18n/locales/pt-BR.ts @@ -3328,6 +3328,10 @@ export const pt_BR: TranslationMap = { catalog: { remoteViewOnly: "Esta sessão está em um nó pareado e é somente para visualização.", unsupportedViewOnly: "Esta fonte de sessão externa é somente para visualização.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "Acompanhamento sugerido", @@ -3371,6 +3375,9 @@ export const pt_BR: TranslationMap = { sendShortcut: "Atalho de envio", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "Desativado durante a integração", gatewayStatus: "Status do Gateway: {status}", commandPaletteTitle: "Pesquisar ou ir para… (⌘K)", diff --git a/ui/src/i18n/locales/ru.ts b/ui/src/i18n/locales/ru.ts index c6a17253af7a..25ec1d172695 100644 --- a/ui/src/i18n/locales/ru.ts +++ b/ui/src/i18n/locales/ru.ts @@ -3343,6 +3343,10 @@ export const ru: TranslationMap = { catalog: { remoteViewOnly: "Этот сеанс находится на сопряжённом узле и доступен только для просмотра.", unsupportedViewOnly: "Этот внешний источник сеансов доступен только для просмотра.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "Предлагаемая следующая задача", @@ -3387,6 +3391,9 @@ export const ru: TranslationMap = { sendShortcut: "Сочетание клавиш для отправки", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Терминал", onboardingDisabled: "Отключено во время настройки", gatewayStatus: "Статус Gateway: {status}", commandPaletteTitle: "Поиск или переход к… (⌘K)", diff --git a/ui/src/i18n/locales/th.ts b/ui/src/i18n/locales/th.ts index fed66abd1028..fc68736f3e4e 100644 --- a/ui/src/i18n/locales/th.ts +++ b/ui/src/i18n/locales/th.ts @@ -3257,6 +3257,10 @@ export const th: TranslationMap = { catalog: { remoteViewOnly: "เซสชันนี้อยู่บนโหนดที่จับคู่และดูได้อย่างเดียว", unsupportedViewOnly: "แหล่งที่มาของเซสชันภายนอกนี้ดูได้อย่างเดียว", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "งานติดตามผลที่แนะนำ", @@ -3300,6 +3304,9 @@ export const th: TranslationMap = { sendShortcut: "ปุ่มลัดสำหรับส่ง", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "ปิดใช้งานระหว่างการตั้งค่า", gatewayStatus: "สถานะ Gateway: {status}", commandPaletteTitle: "ค้นหาหรือข้ามไปที่… (⌘K)", diff --git a/ui/src/i18n/locales/tr.ts b/ui/src/i18n/locales/tr.ts index 9a690dfec2ab..ed24a37fe5f1 100644 --- a/ui/src/i18n/locales/tr.ts +++ b/ui/src/i18n/locales/tr.ts @@ -3337,6 +3337,10 @@ export const tr: TranslationMap = { catalog: { remoteViewOnly: "Bu oturum eşleştirilmiş bir düğümdedir ve yalnızca görüntülenebilir.", unsupportedViewOnly: "Bu harici oturum kaynağı yalnızca görüntülenebilir.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "Önerilen takip görevi", @@ -3380,6 +3384,9 @@ export const tr: TranslationMap = { sendShortcut: "Gönderme kısayolu", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "Kurulum sırasında devre dışı", gatewayStatus: "Gateway durumu: {status}", commandPaletteTitle: "Ara veya şuraya git… (⌘K)", diff --git a/ui/src/i18n/locales/uk.ts b/ui/src/i18n/locales/uk.ts index 38347a7ee737..d344fcbe16bb 100644 --- a/ui/src/i18n/locales/uk.ts +++ b/ui/src/i18n/locales/uk.ts @@ -3324,6 +3324,10 @@ export const uk: TranslationMap = { catalog: { remoteViewOnly: "Цей сеанс розташований на спареному вузлі й доступний лише для перегляду.", unsupportedViewOnly: "Це зовнішнє джерело сеансів доступне лише для перегляду.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "Запропоноване подальше завдання", @@ -3368,6 +3372,9 @@ export const uk: TranslationMap = { sendShortcut: "Комбінація для надсилання", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "Вимкнено під час налаштування", gatewayStatus: "Стан Gateway: {status}", commandPaletteTitle: "Пошук або перехід до… (⌘K)", diff --git a/ui/src/i18n/locales/vi.ts b/ui/src/i18n/locales/vi.ts index 32d385ac9734..44f8fbbb2081 100644 --- a/ui/src/i18n/locales/vi.ts +++ b/ui/src/i18n/locales/vi.ts @@ -3312,6 +3312,10 @@ export const vi: TranslationMap = { catalog: { remoteViewOnly: "Phiên này nằm trên một nút đã ghép nối và chỉ có thể xem.", unsupportedViewOnly: "Nguồn phiên bên ngoài này chỉ có thể xem.", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "Tác vụ tiếp theo được đề xuất", @@ -3355,6 +3359,9 @@ export const vi: TranslationMap = { sendShortcut: "Phím tắt gửi", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "Đã tắt trong quá trình thiết lập", gatewayStatus: "Trạng thái Gateway: {status}", commandPaletteTitle: "Tìm kiếm hoặc chuyển đến… (⌘K)", diff --git a/ui/src/i18n/locales/zh-CN.ts b/ui/src/i18n/locales/zh-CN.ts index 32165be3dbae..485da1bdec7a 100644 --- a/ui/src/i18n/locales/zh-CN.ts +++ b/ui/src/i18n/locales/zh-CN.ts @@ -3240,6 +3240,10 @@ export const zh_CN: TranslationMap = { catalog: { remoteViewOnly: "此会话位于已配对节点上,仅供查看。", unsupportedViewOnly: "此外部会话来源仅供查看。", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "建议的后续任务", @@ -3282,6 +3286,9 @@ export const zh_CN: TranslationMap = { sendShortcut: "发送快捷键", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "引导期间禁用", gatewayStatus: "Gateway 状态:{status}", commandPaletteTitle: "搜索或跳转到… (⌘K)", diff --git a/ui/src/i18n/locales/zh-TW.ts b/ui/src/i18n/locales/zh-TW.ts index 1ff70bc9ae30..35a11839ce4e 100644 --- a/ui/src/i18n/locales/zh-TW.ts +++ b/ui/src/i18n/locales/zh-TW.ts @@ -3245,6 +3245,10 @@ export const zh_TW: TranslationMap = { catalog: { remoteViewOnly: "此工作階段位於已配對節點上,僅供檢視。", unsupportedViewOnly: "此外部工作階段來源僅供檢視。", + sessionMenu: "External session actions", + openInOpenClaw: "Open in OpenClaw", + openInTerminal: "Open in terminal", + terminalUnavailable: "Terminal opening is unavailable for this session.", }, taskSuggestions: { eyebrow: "建議的後續任務", @@ -3287,6 +3291,9 @@ export const zh_TW: TranslationMap = { sendShortcut: "傳送快捷鍵", sendShortcutEnter: "Enter", sendShortcutModifierEnter: "⌘/Ctrl+Enter", + catalogOpenTarget: "Open Codex/Claude sessions in", + catalogOpenTargetViewer: "OpenClaw viewer", + catalogOpenTargetTerminal: "Terminal", onboardingDisabled: "引導期間禁用", gatewayStatus: "Gateway 狀態:{status}", commandPaletteTitle: "搜尋或跳至… (⌘K)", diff --git a/ui/src/lib/sessions/catalog-terminal.ts b/ui/src/lib/sessions/catalog-terminal.ts new file mode 100644 index 000000000000..c7394f9c4777 --- /dev/null +++ b/ui/src/lib/sessions/catalog-terminal.ts @@ -0,0 +1,13 @@ +import { + TERMINAL_PANEL_TOGGLE_EVENT, + type TerminalPanelToggleDetail, +} from "../../components/panel-toggle-contract.ts"; +import type { CatalogSessionKey } from "./catalog-key.ts"; + +export function openCatalogSessionInTerminal(key: CatalogSessionKey): void { + window.dispatchEvent( + new CustomEvent(TERMINAL_PANEL_TOGGLE_EVENT, { + detail: { open: true, catalog: key }, + }), + ); +} diff --git a/ui/src/lib/terminal-availability.ts b/ui/src/lib/terminal-availability.ts new file mode 100644 index 000000000000..cc2c3a47c12c --- /dev/null +++ b/ui/src/lib/terminal-availability.ts @@ -0,0 +1,15 @@ +import type { ApplicationGatewaySnapshot } from "../app/context.ts"; +import { hasOperatorAdminAccess } from "../app/operator-access.ts"; +import { isGatewayMethodAdvertised } from "./gateway-methods.ts"; + +export function isTerminalAvailable( + snapshot: ApplicationGatewaySnapshot, + terminalEnabled: boolean, +): boolean { + return ( + (snapshot.connected ?? false) && + terminalEnabled && + hasOperatorAdminAccess(snapshot.hello?.auth ?? null) && + (isGatewayMethodAdvertised(snapshot, "terminal.open") ?? false) + ); +} diff --git a/ui/src/pages/chat/catalog-message-id.ts b/ui/src/pages/chat/catalog-message-id.ts new file mode 100644 index 000000000000..192d4ac2a095 --- /dev/null +++ b/ui/src/pages/chat/catalog-message-id.ts @@ -0,0 +1,8 @@ +// Catalog transcript pagination owns its message-id normalization separately +// from live history merging so wrapped provider records keep their identity. +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; + +export function catalogMessageId(message: unknown): string | null { + const messageId = asNullableRecord(message)?.messageId; + return typeof messageId === "string" && messageId ? messageId : null; +} diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index 10584fed61f5..6f9342358ed9 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -1,5 +1,6 @@ /* @vitest-environment jsdom */ +import { render, type TemplateResult } from "lit"; import { describe, expect, it, vi } from "vitest"; import type { SessionCatalogSession, @@ -18,10 +19,12 @@ import type { SessionCapability } from "../../lib/sessions/index.ts"; import "./chat-pane.ts"; import { loadChatHistory } from "./chat-history.ts"; import type { ChatPageHost } from "./chat-state.ts"; +import { createBackgroundTasksProps } from "./components/chat-background-tasks.ts"; import { createSessionWorkspaceProps } from "./components/chat-session-workspace.ts"; import type { SidebarContent } from "./components/chat-sidebar.ts"; type TestChatPane = HTMLElement & { + catalogMessages: unknown[]; active: boolean; chatState: { attach: (state: ChatPageHost) => void }; context: ApplicationContext; @@ -49,6 +52,7 @@ type TestChatPane = HTMLElement & { syncHistoryObserver: () => void; loadCatalogSession: (key: CatalogSessionKey, older: boolean) => Promise; prependUniqueNativeMessages: (messages: unknown[], current: unknown[]) => unknown[]; + prependUniqueCatalogMessages: (messages: unknown[]) => unknown[]; loadOlderMessages: () => Promise; hasOlderMessages: () => boolean; restoreHistoryAnchor: () => void; @@ -58,6 +62,10 @@ type TestChatPane = HTMLElement & { olderCursorsSeen: Set; olderOffsetsSeen: Set; nativeHistoryExpanded: boolean; + renderPaneHeader: ( + workspace: ReturnType, + tasks: ReturnType, + ) => TemplateResult; }; const suggestion: TaskSuggestion = { @@ -380,6 +388,45 @@ describe("chat pane session creation lifecycle", () => { }); describe("chat pane catalog session lifecycle", () => { + it("shows the eligible catalog terminal action and dispatches its typed reference", () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ client, sessions: {} as SessionCapability }); + const key = { + catalogId: "codex", + hostId: "gateway:local", + threadId: "thread-101", + } satisfies CatalogSessionKey; + state.sessionKey = buildCatalogSessionKey(key); + state.terminalAvailable = true; + pane.catalogSession = { + threadId: key.threadId, + status: "idle", + archived: false, + canContinue: true, + canArchive: true, + canOpenTerminal: true, + }; + const container = document.createElement("div"); + render( + pane.renderPaneHeader( + createSessionWorkspaceProps(state), + createBackgroundTasksProps(state, { onOpenSession: () => {} }), + ), + container, + ); + let detail: unknown; + const listener = (event: Event) => { + detail = (event as CustomEvent).detail; + }; + window.addEventListener("openclaw:terminal-toggle", listener); + try { + (container.querySelector('[aria-label="Open in terminal"]') as HTMLElement).click(); + } finally { + window.removeEventListener("openclaw:terminal-toggle", listener); + } + expect(detail).toEqual({ open: true, catalog: key }); + }); + it("finds continuation metadata on a later catalog page", async () => { const key = { catalogId: "codex", @@ -694,6 +741,25 @@ describe("chat pane native history pagination", () => { ).toEqual([projected[0], projected[1], nativeHistoryMessage(2)]); }); + it("deduplicates projected catalog transcript records by catalog message id", () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const { pane } = createTestChatPane({ client, sessions: {} as SessionCapability }); + const current = pane.catalogItemMessage( + { id: "catalog-item-1", type: "userMessage", text: "newer projection" }, + 0, + ); + const overlapping = pane.catalogItemMessage( + { id: "catalog-item-1", type: "userMessage", text: "older projection" }, + 1, + ); + if (!current || !overlapping) { + throw new Error("expected catalog transcript projections"); + } + pane.catalogMessages = [current]; + + expect(pane.prependUniqueCatalogMessages([overlapping])).toEqual([current]); + }); + it("prepends a strictly older page, preserves the viewport, and exhausts", async () => { const request = vi.fn(async () => ({ messages: [nativeHistoryMessage(1), nativeHistoryMessage(2)], diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index b0b185cd2e65..f7ecb859ca74 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -61,6 +61,7 @@ import { import { SessionUnreadPatchGuard } from "../../lib/sessions/unread.ts"; import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts"; import { PollController } from "../../lit/poll-controller.ts"; +import { catalogMessageId } from "./catalog-message-id.ts"; import { refreshChatAvatar } from "./chat-avatar.ts"; import { applyChatAgentsList, @@ -100,6 +101,7 @@ import { type ChatPageHost, } from "./chat-state.ts"; import { renderChat, resetChatViewState, type ChatProps } from "./chat-view.ts"; +import { renderCatalogTerminalButton } from "./components/catalog-terminal-button.ts"; import { createBackgroundTasksProps, renderBackgroundTasksToggle, @@ -199,10 +201,6 @@ function nativeHistoryMessageIdentity(message: unknown): string | null { } } -function catalogMessageId(message: unknown): string | null { - const messageId = catalogRawRecord(message)?.messageId; - return typeof messageId === "string" && messageId ? messageId : null; -} type ChatPaneConnectionScope = { context: ChatPageContext; state: ChatPageHost; @@ -1733,6 +1731,7 @@ class ChatPane extends OpenClawLightDomElement { drag-and-drop. --> ${this.paneTitle}
+ ${renderCatalogTerminalButton(this.state, this.catalogSession)} ${renderSessionDiffToggle(sessionWorkspace)} ${renderBackgroundTasksToggle(backgroundTasks)} ${renderSessionWorkspaceToggle(sessionWorkspace)} diff --git a/ui/src/pages/chat/components/catalog-terminal-button.ts b/ui/src/pages/chat/components/catalog-terminal-button.ts new file mode 100644 index 000000000000..3fcd0e7e96e8 --- /dev/null +++ b/ui/src/pages/chat/components/catalog-terminal-button.ts @@ -0,0 +1,30 @@ +// Catalog viewer header action for opening an eligible native session in a terminal. +import { html, nothing } from "lit"; +import type { SessionCatalogSession } from "../../../../../packages/gateway-protocol/src/index.js"; +import { icons } from "../../../components/icons.ts"; +import "../../../components/tooltip.ts"; +import { t } from "../../../i18n/index.ts"; +import { parseCatalogSessionKey } from "../../../lib/sessions/catalog-key.ts"; +import { openCatalogSessionInTerminal } from "../../../lib/sessions/catalog-terminal.ts"; + +export function renderCatalogTerminalButton( + state: { sessionKey: string; terminalAvailable?: boolean } | null | undefined, + session: SessionCatalogSession | null, +) { + const catalogKey = state ? parseCatalogSessionKey(state.sessionKey) : null; + if (!catalogKey || !session?.canOpenTerminal || !state?.terminalAvailable) { + return nothing; + } + return html` + + + + `; +} diff --git a/ui/src/pages/config/config-page.ts b/ui/src/pages/config/config-page.ts index 2a52e3fa2a59..39c27cdba4f1 100644 --- a/ui/src/pages/config/config-page.ts +++ b/ui/src/pages/config/config-page.ts @@ -15,10 +15,10 @@ import { importCustomThemeFromUrl } from "../../app/custom-theme.ts"; import { hasOperatorAdminAccess } from "../../app/operator-access.ts"; import { loadSettings, + normalizeCatalogOpenTarget, normalizeTextScale, normalizeChatSendShortcut, patchSettings, - type ChatSendShortcut, type UiSettings, } from "../../app/settings.ts"; import { startThemeTransition } from "../../app/theme-transition.ts"; @@ -61,6 +61,7 @@ export type { ConfigPageId } from "./config-sections.ts"; type ConfigFormMode = "form" | "raw"; type ConfigSelection = { activeSection: string | null; activeSubsection: string | null }; +type LocalUiSetting = "textScale" | "chatSendShortcut" | "catalogOpenTarget"; const CONFIG_PAGE_I18N_KEYS = { config: "config", @@ -593,6 +594,7 @@ export class ConfigPage extends OpenClawLightDomElement { customTheme: next.customTheme, textScale: next.textScale, chatSendShortcut: next.chatSendShortcut, + catalogOpenTarget: next.catalogOpenTarget, realtimeTalkInputDeviceId: next.realtimeTalkInputDeviceId, lobsterPetVisits: next.lobsterPetVisits, lobsterPetSounds: next.lobsterPetSounds, @@ -636,12 +638,8 @@ export class ConfigPage extends OpenClawLightDomElement { }); } - private setTextScale(value: number) { - this.applySettings({ ...this.settings, textScale: normalizeTextScale(value) }); - } - - private setChatSendShortcut(value: ChatSendShortcut) { - this.applySettings({ ...this.settings, chatSendShortcut: value }); + private setSetting(key: K, value: UiSettings[K]) { + this.applySettings({ ...this.settings, [key]: value }); } private selectMicrophone(deviceId: string) { @@ -798,9 +796,11 @@ export class ConfigPage extends OpenClawLightDomElement { onClearCustomTheme: () => this.clearCustomTheme(), onOpenCustomThemeImport: () => this.openCustomThemeImport(), textScale: this.settings.textScale ?? 100, - setTextScale: (value) => this.setTextScale(value), + setTextScale: (value) => this.setSetting("textScale", normalizeTextScale(value)), chatSendShortcut: normalizeChatSendShortcut(this.settings.chatSendShortcut), - setChatSendShortcut: (value) => this.setChatSendShortcut(value), + setChatSendShortcut: (value) => this.setSetting("chatSendShortcut", value), + catalogOpenTarget: normalizeCatalogOpenTarget(this.settings.catalogOpenTarget), + setCatalogOpenTarget: (value) => this.setSetting("catalogOpenTarget", value), microphone: { devices: this.microphoneDevices, selectedDeviceId: this.settings.realtimeTalkInputDeviceId ?? "", @@ -884,7 +884,7 @@ export class ConfigPage extends OpenClawLightDomElement { }; this.navigate("ai-agents"); }, - setTextScale: (value) => this.setTextScale(value), + setTextScale: (value) => this.setSetting("textScale", normalizeTextScale(value)), lobsterPetVisits: this.settings.lobsterPetVisits !== false, setLobsterPetVisits: (enabled) => this.applySettings({ ...this.settings, lobsterPetVisits: enabled }), diff --git a/ui/src/pages/config/settings-search.ts b/ui/src/pages/config/settings-search.ts index e7ee4a703c35..345273b7877d 100644 --- a/ui/src/pages/config/settings-search.ts +++ b/ui/src/pages/config/settings-search.ts @@ -171,10 +171,13 @@ const APPEARANCE_SETTINGS_BLOCKS = { "chat.sendShortcut", "chat.sendShortcutEnter", "chat.sendShortcutModifierEnter", + "chat.catalogOpenTarget", + "chat.catalogOpenTargetViewer", + "chat.catalogOpenTargetTerminal", "chat.composer.microphoneInput", "chat.composer.systemDefaultMicrophone", ], - aliases: "keyboard enter microphone voice audio input", + aliases: "keyboard enter microphone voice audio input codex claude terminal viewer", }, connection: { routeId: "appearance", diff --git a/ui/src/pages/config/settings-select-row.ts b/ui/src/pages/config/settings-select-row.ts new file mode 100644 index 000000000000..35f2aa758d3d --- /dev/null +++ b/ui/src/pages/config/settings-select-row.ts @@ -0,0 +1,34 @@ +// Shared labeled select rendering for compact Control UI preference rows. +import { html } from "lit"; +import { renderSettingsRow } from "../../components/settings-ui.ts"; + +export function renderSettingsSelectRow(params: { + title: string; + value: T; + setting: "send-shortcut" | "catalog-open-target"; + options: ReadonlyArray<{ value: T; label: string }>; + onChange: (value: string) => void; +}) { + return renderSettingsRow({ + title: params.title, + control: html` + + `, + }); +} diff --git a/ui/src/pages/config/view.browser.test.ts b/ui/src/pages/config/view.browser.test.ts index fe1145aace1c..1e210bcd70e2 100644 --- a/ui/src/pages/config/view.browser.test.ts +++ b/ui/src/pages/config/view.browser.test.ts @@ -63,6 +63,8 @@ describe("config view", () => { setTextScale: vi.fn(), chatSendShortcut: "enter" as const, setChatSendShortcut: vi.fn(), + catalogOpenTarget: "viewer" as const, + setCatalogOpenTarget: vi.fn(), gatewayUrl: "", assistantName: "OpenClaw", }); diff --git a/ui/src/pages/config/view.ts b/ui/src/pages/config/view.ts index 717e09b5ed40..3df21064a12e 100644 --- a/ui/src/pages/config/view.ts +++ b/ui/src/pages/config/view.ts @@ -5,8 +5,10 @@ import { html, nothing, type TemplateResult } from "lit"; import type { ConfigUiHints } from "../../api/types.ts"; import { normalizeChatSendShortcut, + normalizeCatalogOpenTarget, TEXT_SCALE_STOPS, type ChatSendShortcut, + type CatalogOpenTarget, type TextScaleStop, } from "../../app/settings.ts"; import type { ThemeTransitionContext } from "../../app/theme-transition.ts"; @@ -35,6 +37,7 @@ import { } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; import type { RealtimeTalkInputDevice } from "../chat/realtime-talk-input.ts"; +import { renderSettingsSelectRow } from "./settings-select-row.ts"; import { APPEARANCE_SETTINGS_TARGET_IDS, COMMUNICATION_SETTINGS_TARGET_IDS, @@ -165,6 +168,8 @@ export type ConfigProps = { setTextScale: (value: number) => void; chatSendShortcut: ChatSendShortcut; setChatSendShortcut: (value: ChatSendShortcut) => void; + catalogOpenTarget: CatalogOpenTarget; + setCatalogOpenTarget: (value: CatalogOpenTarget) => void; microphone?: SettingsMicrophoneState; onMicrophoneRefresh?: () => void; onMicrophoneSelect?: (deviceId: string) => void; @@ -1094,30 +1099,25 @@ function renderChatPreferencesSection(props: ConfigProps) {

${t("configView.chatPrefs.hint")}

- ${renderSettingsRow({ + ${renderSettingsSelectRow({ title: t("chat.sendShortcut"), - control: html` - - `, + value: props.chatSendShortcut, + setting: "send-shortcut", + options: [ + { value: "enter", label: t("chat.sendShortcutEnter") }, + { value: "modifier-enter", label: t("chat.sendShortcutModifierEnter") }, + ], + onChange: (value) => props.setChatSendShortcut(normalizeChatSendShortcut(value)), + })} + ${renderSettingsSelectRow({ + title: t("chat.catalogOpenTarget"), + value: props.catalogOpenTarget, + setting: "catalog-open-target", + options: [ + { value: "viewer", label: t("chat.catalogOpenTargetViewer") }, + { value: "terminal", label: t("chat.catalogOpenTargetTerminal") }, + ], + onChange: (value) => props.setCatalogOpenTarget(normalizeCatalogOpenTarget(value)), })} ${renderSettingsMicrophoneField(props)}