Add remote dashboard bridge

This commit is contained in:
matamune 2026-05-29 17:21:12 +00:00
parent 9b417f622f
commit 91316becd0
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
29 changed files with 1609 additions and 12 deletions

View file

@ -1,6 +1,6 @@
{
"name": "codex-flows",
"version": "0.132.6",
"version": "0.133.4",
"description": "Full Codex turn automation, backend setup, backend operation, and workspace orchestration guidance.",
"author": {
"name": "Peezy",
@ -38,7 +38,8 @@
"Set up the local Codex workspace backend.",
"Discover my Codex App remote connections and run codex-flows over SSH.",
"Run codex-flows against a remote Codex workspace over SSH.",
"Coordinate delegated workspace work."
"Coordinate delegated workspace work.",
"Build a local Vite dashboard for a remote workspace over SSH."
],
"brandColor": "#2563EB"
}

View file

@ -69,6 +69,8 @@ codex-flows --ssh devbox --cwd /repo automation run openai-codex-bindings --even
codex-flows --ssh devbox --cwd /repo fetch
codex-flows --ssh devbox --cwd /repo remote preflight
codex-flows --ssh devbox --cwd /repo app thread/list --params-json '{"limit":20,"sourceKinds":[]}'
codex-flows --ssh devbox --cwd /repo functions list --json
codex-flows --ssh devbox --cwd /repo functions call portfolioSnapshot --json
codex-flows --ssh devbox --cwd /repo turn run "Scan current folder" --wait --sandbox danger-full-access --approval-policy never
codex-flows workspace doctor
codex-flows workspace backend status

View file

@ -7,6 +7,7 @@ import {
CodexWorkspaceBackendProtocolServer,
type CodexWorkspaceBackendPeer,
} from "@peezy.tech/codex-flows/workspace-backend";
import { createWorkspaceFunctionMethods } from "@peezy.tech/codex-flows/functions";
import http from "node:http";
import { WebSocketServer, type RawData, type WebSocket } from "ws";
@ -30,6 +31,9 @@ async function main(): Promise<void> {
appServer: client,
serverName: "codex-workspace-backend-local",
serverVersion: "0.1.0",
methods: createWorkspaceFunctionMethods({
cwd: parsed.cwd,
}),
});
const peers = new WeakMap<WebSocket, CodexWorkspaceBackendPeer>();
const server = http.createServer((_request, response) => {

View file

@ -18,6 +18,7 @@
"baseUrl": ".",
"paths": {
"@peezy.tech/codex-flows": ["../../packages/codex-client/src/index.ts"],
"@peezy.tech/codex-flows/functions": ["../../packages/codex-client/src/functions.ts"],
"@peezy.tech/codex-flows/workspace-backend": ["../../packages/codex-client/src/workspace-backend/index.ts"]
}
},

View file

@ -1,6 +1,6 @@
{
"name": "@peezy.tech/codex-flow-docs",
"version": "0.133.3",
"version": "0.133.4",
"private": true,
"type": "module",
"scripts": {

View file

@ -57,6 +57,8 @@ targeting a remote workspace:
codex-flows --ssh devbox --cwd /repo remote preflight
codex-flows --ssh devbox --cwd /repo app thread/list --params-json '{"limit":20,"sourceKinds":[]}'
codex-flows --ssh devbox --cwd /repo workspace delegation.list
codex-flows --ssh devbox --cwd /repo functions list --json
codex-flows --ssh devbox --cwd /repo functions call portfolioSnapshot --json
codex-flows --ssh devbox --cwd /repo automation run check-release --event event.json
codex-flows --ssh devbox --cwd /repo turn run "Scan current folder" --wait --sandbox danger-full-access --approval-policy never
```
@ -179,6 +181,23 @@ If an older PowerShell native-command mode strips JSON quotes before argv
delivery, `--params-json` also accepts the common stripped shape for simple
objects, for example `{limit:3,sourceKinds:[]}`.
## Workspace Functions
```bash
codex-flows functions list [--json]
codex-flows functions describe <name> [--json]
codex-flows functions call <name> [--params-json <json>] [--json]
codex-flows --ssh <target> --cwd <remote-workspace> functions list --json
codex-flows --ssh <target> --cwd <remote-workspace> functions describe <name> --json
codex-flows --ssh <target> --cwd <remote-workspace> functions call <name> --params-json '{"sample":true}' --json
```
Workspace functions are JSON-in/JSON-out helpers loaded from
`.codex/functions.ts`, `.codex/functions.js`, or `.codex/functions.mjs` in the
target workspace. They are intended for local dashboards and operators that need
active workspace data without starting a Codex turn or exposing a remote HTTP
server. With `--ssh`, calls run through the SSH remote-agent provider.
## Workspace Backend Calls
```bash

View file

@ -14,8 +14,11 @@ exports:
- turn automation helpers for pre-turn scripts that can skip or start native
Codex turns
- SSH remote provider helpers for targeting remote workspaces from a local CLI
- workspace functions under `@peezy.tech/codex-flows/functions`
- Vite bridge plugin under `@peezy.tech/codex-flows/vite`
- browser-safe workspace backend client and protocol server primitives
- browser-safe WebSocket transport
- browser-safe WebSocket transport and dashboard client under
`@peezy.tech/codex-flows/browser`
- auth helpers for account login/status/usage
- Actions-mode workspace helpers under `@peezy.tech/codex-flows/actions`
- stable Codex memory artifact helpers under `@peezy.tech/codex-flows/memories`
@ -50,6 +53,51 @@ Actions-mode simulation:
These helpers intentionally do not inspect or mutate Codex memory SQLite
internals.
## `@peezy.tech/codex-flows/functions`
Workspace functions expose named JSON-in/JSON-out capabilities from a workspace
manifest at `.codex/functions.ts`, `.codex/functions.js`, or
`.codex/functions.mjs`.
```ts
import { defineFunctions } from "@peezy.tech/codex-flows/functions";
export default defineFunctions({
portfolioSnapshot: {
description: "Read the latest portfolio snapshot.",
sideEffects: "read-only",
handler: async () => ({ positions: [], cash: 0 }),
},
});
```
The CLI, workspace backend, SSH remote-agent, Vite plugin, and browser client
use the same `functions.list`, `functions.describe`, and `functions.call`
workspace methods.
## `@peezy.tech/codex-flows/vite`
`codexFlowsRemote` is a local Vite middleware plugin that forwards dashboard
requests to a workspace backend or SSH remote-agent without exposing remote HTTP
ports.
```ts
import { codexFlowsRemote } from "@peezy.tech/codex-flows/vite";
export default {
plugins: [
codexFlowsRemote({
ssh: process.env.CODEX_FLOWS_REMOTE_SSH_TARGET,
cwd: process.env.CODEX_FLOWS_REMOTE_CWD,
}),
],
};
```
Dashboard code can use `codexFlows` from
`@peezy.tech/codex-flows/browser` to list, describe, and call workspace
functions through the local Vite bridge.
## `@peezy.tech/codex-flows/memories`
The memory transplant helpers operate on stable markdown artifacts only.

View file

@ -1,6 +1,6 @@
{
"name": "codex-flows-monorepo",
"version": "0.133.3",
"version": "0.133.4",
"description": "Codex app-server clients, turn automation, workspace backend tools, and repo-native workspace operations.",
"private": true,
"license": "Apache-2.0",

View file

@ -30,7 +30,9 @@ Full documentation lives in the repo docs site:
| Export | Purpose |
|--------|---------|
| `@peezy.tech/codex-flows` | Node app-server client, turn automation helpers, SSH provider helpers, event emitter base, stdio/WebSocket transports, JSON-RPC helpers, auth helpers. |
| `@peezy.tech/codex-flows/browser` | Browser-safe app-server client and WebSocket transport. |
| `@peezy.tech/codex-flows/browser` | Browser-safe app-server client, WebSocket transport, and local dashboard bridge client. |
| `@peezy.tech/codex-flows/functions` | Workspace function definitions and backend method helpers. |
| `@peezy.tech/codex-flows/vite` | Vite middleware plugin for local dashboards that call remote workspace functions. |
| `@peezy.tech/codex-flows/auth` | Privacy-preserving Codex account login, status, and usage helpers. |
| `@peezy.tech/codex-flows/workbench` | Transport-neutral thread UX reducers and app-server request descriptors. |
| `@peezy.tech/codex-flows/threads` | Raw Codex rollout locate, inspect, install, and transplant helpers. |
@ -147,6 +149,8 @@ codex-flows automation run openai-codex-bindings --event event.json
codex-flows --ssh devbox --cwd /repo automation run openai-codex-bindings --event event.json
codex-flows --ssh devbox --cwd /repo fetch
codex-flows --ssh devbox --cwd /repo app thread/list --params-json '{"limit":20,"sourceKinds":[]}'
codex-flows --ssh devbox --cwd /repo functions list --json
codex-flows --ssh devbox --cwd /repo functions call portfolioSnapshot --json
codex-flows --ssh devbox --cwd /repo turn run "Scan current folder" --wait --sandbox danger-full-access --approval-policy never
codex-flows app thread/list --params-json '{"limit":20,"sourceKinds":[]}'
codex-flows workspace app thread/list --params-json '{"limit":20,"sourceKinds":[]}'

View file

@ -1,6 +1,6 @@
{
"name": "@peezy.tech/codex-flows",
"version": "0.133.3",
"version": "0.133.4",
"description": "Codex app-server clients, turn automation, workspace backend helpers, and runnable local backend bins.",
"type": "module",
"license": "Apache-2.0",
@ -40,6 +40,16 @@
"require": "./dist/browser.js",
"import": "./dist/browser.js"
},
"./functions": {
"types": "./dist/functions.d.ts",
"require": "./dist/functions.js",
"import": "./dist/functions.js"
},
"./vite": {
"types": "./dist/vite.d.ts",
"require": "./dist/vite.js",
"import": "./dist/vite.js"
},
"./auth": {
"types": "./dist/auth.d.ts",
"require": "./dist/auth.js",

View file

@ -1,6 +1,8 @@
const checks = [
["@peezy.tech/codex-flows", ["CodexAppServerClient", "CodexEventEmitter"]],
["@peezy.tech/codex-flows/browser", ["CodexAppServerClient"]],
["@peezy.tech/codex-flows/browser", ["CodexAppServerClient", "codexFlows"]],
["@peezy.tech/codex-flows/functions", ["defineFunctions", "createWorkspaceFunctionMethods"]],
["@peezy.tech/codex-flows/vite", ["codexFlowsRemote"]],
["@peezy.tech/codex-flows/auth", ["CodexAuthClient", "createCodexAuthClient"]],
["@peezy.tech/codex-flows/actions", ["repoCodexHome", "prepareActionsCodexAuth"]],
["@peezy.tech/codex-flows/memories", ["listCodexMemoryArtifacts"]],

View file

@ -1,3 +1,10 @@
import type {
WorkspaceFunctionMetadata,
WorkspaceFunctionsCallResponse,
WorkspaceFunctionsDescribeResponse,
WorkspaceFunctionsListResponse,
} from "./functions.ts";
export {
CodexBrowserAppServerClient as CodexAppServerClient,
type CodexBrowserAppServerClientOptions as CodexAppServerClientOptions,
@ -29,6 +36,12 @@ export type {
JsonRpcResponse,
} from "./app-server/rpc.ts";
export type { v2 } from "./app-server/generated/index.ts";
export type {
WorkspaceFunctionMetadata,
WorkspaceFunctionsCallResponse,
WorkspaceFunctionsDescribeResponse,
WorkspaceFunctionsListResponse,
} from "./functions.ts";
export {
CodexAuthClient,
CodexAuthTimeoutError,
@ -51,3 +64,74 @@ export type {
CodexUsageWindow,
WaitForLoginOptions,
} from "./app-server/auth.ts";
export type CodexFlowsBrowserClientOptions = {
basePath?: string;
fetch?: typeof fetch;
};
export type CodexFlowsBrowserFunctionsClient = {
list(): Promise<WorkspaceFunctionMetadata[]>;
describe(name: string): Promise<WorkspaceFunctionMetadata>;
call<T = unknown>(name: string, params?: unknown): Promise<T>;
};
export type CodexFlowsBrowserClient = {
functions: CodexFlowsBrowserFunctionsClient;
status(): Promise<unknown>;
};
export function createCodexFlowsBrowserClient(
options: CodexFlowsBrowserClientOptions = {},
): CodexFlowsBrowserClient {
const basePath = (options.basePath ?? "/__codex_flows").replace(/\/$/, "");
const fetchImpl = options.fetch ?? fetch;
return {
status: async () => await requestJson(fetchImpl, `${basePath}/status`),
functions: {
list: async () => {
const response = await requestJson<WorkspaceFunctionsListResponse>(
fetchImpl,
`${basePath}/functions`,
);
return response.functions;
},
describe: async (name) => {
const response = await requestJson<WorkspaceFunctionsDescribeResponse>(
fetchImpl,
`${basePath}/functions/${encodeURIComponent(name)}`,
);
return response.function;
},
call: async <T = unknown>(name: string, params?: unknown) => {
const response = await requestJson<WorkspaceFunctionsCallResponse>(
fetchImpl,
`${basePath}/functions/${encodeURIComponent(name)}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ params }),
},
);
return response.result as T;
},
},
};
}
export const codexFlows = createCodexFlowsBrowserClient();
async function requestJson<T = unknown>(
fetchImpl: typeof fetch,
url: string,
init?: RequestInit,
): Promise<T> {
const response = await fetchImpl(url, init);
const text = await response.text();
const parsed = text ? JSON.parse(text) as unknown : undefined;
if (!response.ok) {
const input = parsed && typeof parsed === "object" ? parsed as { error?: unknown } : {};
throw new Error(typeof input.error === "string" ? input.error : `Codex Flows request failed: ${response.status}`);
}
return parsed as T;
}

View file

@ -33,6 +33,9 @@ export const COMMON_WORKSPACE_BACKEND_METHODS = [
"delegation.setPolicy",
"delegation.flushResults",
"delegation.listGroups",
"functions.list",
"functions.describe",
"functions.call",
] as const;
export function validateMethodName(value: string, label: string): string {

View file

@ -120,6 +120,31 @@ type ParsedCliBase =
timeoutMs: number;
pretty: boolean;
}
| {
type: "functions-list";
url: string;
timeoutMs: number;
json: boolean;
pretty: boolean;
}
| {
type: "functions-describe";
name: string;
url: string;
timeoutMs: number;
json: boolean;
pretty: boolean;
}
| {
type: "functions-call";
name: string;
paramsText?: string;
paramsFile?: string;
url: string;
timeoutMs: number;
json: boolean;
pretty: boolean;
}
| { type: "workspace-methods"; url: string; timeoutMs: number; pretty: boolean }
| {
type: "workspace-doctor";
@ -909,6 +934,43 @@ export function parseArgs(
...remoteFields(),
};
}
if (command === "functions" || command === "function") {
const subcommand = positionals[1];
if (subcommand === "list" || subcommand === "ls") {
return {
type: "functions-list",
url: workspaceUrl,
timeoutMs,
json,
pretty,
...remoteFields(),
};
}
if (subcommand === "describe" || subcommand === "show") {
return {
type: "functions-describe",
name: requiredPositional(positionals, 2, "functions describe requires <name>"),
url: workspaceUrl,
timeoutMs,
json,
pretty,
...remoteFields(),
};
}
if (subcommand === "call" || subcommand === "run") {
return {
type: "functions-call",
name: requiredPositional(positionals, 2, "functions call requires <name>"),
...paramsSource(positionals.slice(3), paramsJson, paramsFile),
url: workspaceUrl,
timeoutMs,
json,
pretty,
...remoteFields(),
};
}
throw new Error("functions requires list, describe, or call");
}
if (command === "workspace") {
const subcommand = positionals[1];
if (!subcommand || subcommand === "methods") {

View file

@ -78,6 +78,15 @@ import {
type RemoteAutomationListResponse,
type RemoteAutomationRunParams,
} from "./remote-automation.ts";
import {
WORKSPACE_FUNCTIONS_CALL_METHOD,
WORKSPACE_FUNCTIONS_DESCRIBE_METHOD,
WORKSPACE_FUNCTIONS_LIST_METHOD,
type WorkspaceFunctionMetadata,
type WorkspaceFunctionsCallResponse,
type WorkspaceFunctionsDescribeResponse,
type WorkspaceFunctionsListResponse,
} from "../functions.ts";
import { serveRemoteAgent } from "./remote-agent.ts";
import type { CodexWorkspaceBackendTransport } from "../workspace-backend/client.ts";
import {
@ -259,6 +268,42 @@ async function main(): Promise<void> {
);
return;
}
if (parsed.type === "functions-list") {
const response = await callWorkspaceBackend(
WORKSPACE_FUNCTIONS_LIST_METHOD,
{},
parsed,
) as WorkspaceFunctionsListResponse;
write(parsed.json
? `${JSON.stringify(response, null, parsed.pretty ? 2 : 0)}\n`
: formatFunctionsList(response.functions));
return;
}
if (parsed.type === "functions-describe") {
const response = await callWorkspaceBackend(
WORKSPACE_FUNCTIONS_DESCRIBE_METHOD,
{ name: parsed.name },
parsed,
) as WorkspaceFunctionsDescribeResponse;
write(parsed.json
? `${JSON.stringify(response, null, parsed.pretty ? 2 : 0)}\n`
: formatFunctionDescription(response.function));
return;
}
if (parsed.type === "functions-call") {
const response = await callWorkspaceBackend(
WORKSPACE_FUNCTIONS_CALL_METHOD,
{
name: parsed.name,
params: await readParams(parsed.paramsText, parsed.paramsFile),
},
parsed,
) as WorkspaceFunctionsCallResponse;
write(parsed.json
? `${JSON.stringify(response, null, parsed.pretty ? 2 : 0)}\n`
: `${JSON.stringify(response.result, null, 2)}\n`);
return;
}
if (parsed.type === "turn-run") {
const result = await startRemoteTurn({
prompt: parsed.prompt,
@ -690,6 +735,20 @@ function validateAutomationTurnOptions(options: {
}
}
function formatFunctionsList(functions: WorkspaceFunctionMetadata[]): string {
if (functions.length === 0) {
return "No workspace functions found.\n";
}
return `${functions.map((fn) => {
const suffix = fn.description ? ` - ${fn.description}` : "";
return `${fn.name} [${fn.sideEffects}]${suffix}`;
}).join("\n")}\n`;
}
function formatFunctionDescription(fn: WorkspaceFunctionMetadata): string {
return `${JSON.stringify(fn, null, 2)}\n`;
}
async function runTurnAutomationForCli(
target: TurnAutomationRunTarget,
options: {
@ -1401,6 +1460,11 @@ Usage:
echo '<params-json>' | codex-flows app <method>
codex-flows app actions
codex-flows functions list [--json]
codex-flows functions describe <name> [--json]
codex-flows functions call <name> [--params-json <json>] [--json]
codex-flows --ssh <target> --cwd <remote-workspace> functions list [--json]
codex-flows workspace <method> [params-json]
codex-flows workspace <method> --params-json <json>
codex-flows workspace <method> --params-file <file>
@ -1507,6 +1571,8 @@ Examples:
codex-flows automation run check-release --event event.json
codex-flows --ssh devbox --cwd /repo automation list --json
codex-flows --ssh devbox --cwd /repo automation run check-release --event event.json
codex-flows --ssh devbox --cwd /repo functions list --json
codex-flows --ssh devbox --cwd /repo functions call portfolioSnapshot --json
codex-flows --ssh devbox --cwd /repo app thread/list '{"limit":20,"sourceKinds":[]}'
codex-flows --ssh devbox --cwd /repo workspace delegation.list
codex-flows app thread/list '{"limit":20,"sourceKinds":[]}'

View file

@ -6,6 +6,7 @@ import {
type CodexWorkspaceBackendPeer,
type WorkspaceBackendMethodHandler,
} from "../workspace-backend/server.ts";
import { createWorkspaceFunctionMethods } from "../functions.ts";
import { createRemoteAutomationMethods } from "./remote-automation.ts";
export type RemoteAgentServeOptions = {
@ -62,6 +63,9 @@ export async function serveRemoteAgent(
codexCommand: options.remoteCodexCommand ?? "codex",
codexArgs: options.remoteCodexArgs ?? [],
});
Object.assign(methods, createWorkspaceFunctionMethods({
cwd: options.cwd,
}));
Object.assign(methods, createRemoteAutomationMethods({
cwd: options.cwd,
timeoutMs: options.timeoutMs,

View file

@ -0,0 +1,334 @@
import { access, stat } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { tsImport } from "tsx/esm/api";
import type { WorkspaceBackendMethodHandler } from "./workspace-backend/server.ts";
export const WORKSPACE_FUNCTIONS_LIST_METHOD = "functions.list";
export const WORKSPACE_FUNCTIONS_DESCRIBE_METHOD = "functions.describe";
export const WORKSPACE_FUNCTIONS_CALL_METHOD = "functions.call";
export type WorkspaceFunctionSideEffects =
| "none"
| "read-only"
| "writes-local"
| "external-write";
export type WorkspaceFunctionMetadata = {
name: string;
description: string;
inputSchema?: unknown;
outputSchema?: unknown;
examples?: unknown;
tags?: string[];
sideEffects: WorkspaceFunctionSideEffects;
timeoutMs?: number;
};
export type WorkspaceFunctionContext = {
cwd: string;
name: string;
};
export type WorkspaceFunctionHandler = (
params: unknown,
context: WorkspaceFunctionContext,
) => unknown | Promise<unknown>;
export type WorkspaceFunctionDefinition =
| WorkspaceFunctionHandler
| {
description?: string;
inputSchema?: unknown;
outputSchema?: unknown;
examples?: unknown;
tags?: string[];
sideEffects?: WorkspaceFunctionSideEffects;
timeoutMs?: number;
handler: WorkspaceFunctionHandler;
};
export type WorkspaceFunctionDefinitions = Record<string, WorkspaceFunctionDefinition>;
export type WorkspaceFunctionsModule =
| WorkspaceFunctionDefinitions
| {
default?: WorkspaceFunctionDefinitions;
functions?: WorkspaceFunctionDefinitions;
};
export type WorkspaceFunctionsListResponse = {
functions: WorkspaceFunctionMetadata[];
};
export type WorkspaceFunctionsDescribeParams = {
name: string;
};
export type WorkspaceFunctionsDescribeResponse = {
function: WorkspaceFunctionMetadata;
};
export type WorkspaceFunctionsCallParams = {
name: string;
params?: unknown;
};
export type WorkspaceFunctionsCallResponse = {
result: unknown;
};
export type WorkspaceFunctionRuntimeOptions = {
cwd?: string;
now?: () => number;
};
export function defineFunctions(
functions: WorkspaceFunctionDefinitions,
): WorkspaceFunctionDefinitions {
return functions;
}
export function createWorkspaceFunctionMethods(
options: WorkspaceFunctionRuntimeOptions = {},
): Record<string, WorkspaceBackendMethodHandler> {
const runtime = new WorkspaceFunctionRuntime(options);
return {
[WORKSPACE_FUNCTIONS_LIST_METHOD]: async () => await runtime.list(),
[WORKSPACE_FUNCTIONS_DESCRIBE_METHOD]: async (params) =>
await runtime.describe(describeParams(params).name),
[WORKSPACE_FUNCTIONS_CALL_METHOD]: async (params) => {
const call = callParams(params);
return await runtime.call(call.name, call.params);
},
};
}
export class WorkspaceFunctionRuntime {
#cwd: string;
#now: () => number;
constructor(options: WorkspaceFunctionRuntimeOptions = {}) {
this.#cwd = path.resolve(options.cwd ?? process.cwd());
this.#now = options.now ?? Date.now;
}
async list(): Promise<WorkspaceFunctionsListResponse> {
const loaded = await this.#load();
return {
functions: [...loaded.entries()]
.map(([name, definition]) => metadataFor(name, definition))
.sort((left, right) => left.name.localeCompare(right.name)),
};
}
async describe(name: string): Promise<WorkspaceFunctionsDescribeResponse> {
const definition = (await this.#load()).get(name);
if (!definition) {
throw new Error(`Workspace function not found: ${name}`);
}
return { function: metadataFor(name, definition) };
}
async call(name: string, params?: unknown): Promise<WorkspaceFunctionsCallResponse> {
const definition = (await this.#load()).get(name);
if (!definition) {
throw new Error(`Workspace function not found: ${name}`);
}
const result = await handlerFor(definition)(params, { cwd: this.#cwd, name });
return { result: jsonRoundTrip(result, `Workspace function returned non-JSON data: ${name}`) };
}
async #load(): Promise<Map<string, WorkspaceFunctionDefinition>> {
const manifestPath = await findFunctionsManifest(this.#cwd);
if (!manifestPath) {
return new Map();
}
const module = await importFunctionsModule(manifestPath, this.#now());
const definitions = definitionsFromModule(module);
return new Map(Object.entries(definitions).map(([name, definition]) => {
if (!isFunctionName(name)) {
throw new Error(`Invalid workspace function name: ${name}`);
}
if (!isWorkspaceFunctionDefinition(definition)) {
throw new Error(`Invalid workspace function definition: ${name}`);
}
return [name, definition];
}));
}
}
export async function findFunctionsManifest(cwd: string): Promise<string | undefined> {
const root = path.resolve(cwd);
for (const name of ["functions.ts", "functions.js", "functions.mjs"]) {
const candidate = path.join(root, ".codex", name);
if (await exists(candidate)) {
return candidate;
}
}
return undefined;
}
async function importFunctionsModule(
manifestPath: string,
cacheBust: number,
): Promise<WorkspaceFunctionsModule> {
const url = pathToFileURL(manifestPath).href;
const version = encodeURIComponent(String((await stat(manifestPath)).mtimeMs || cacheBust));
if (manifestPath.endsWith(".ts")) {
return await tsImport(`${url}?v=${version}`, import.meta.url) as WorkspaceFunctionsModule;
}
return await import(`${url}?v=${version}`) as WorkspaceFunctionsModule;
}
function definitionsFromModule(module: WorkspaceFunctionsModule): WorkspaceFunctionDefinitions {
const definitions = resolveDefinitions(module);
if (definitions) {
return definitions;
}
throw new Error("Workspace functions manifest must export default functions or named functions");
}
function resolveDefinitions(
value: unknown,
depth = 0,
): WorkspaceFunctionDefinitions | undefined {
if (depth > 5) {
return undefined;
}
if (isWorkspaceFunctionDefinitions(value)) {
return value;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const input = value as {
default?: unknown;
functions?: unknown;
"module.exports"?: unknown;
};
return resolveDefinitions(input.default, depth + 1) ??
resolveDefinitions(input.functions, depth + 1) ??
resolveDefinitions(input["module.exports"], depth + 1);
}
function isWorkspaceFunctionDefinitions(
value: unknown,
): value is WorkspaceFunctionDefinitions {
return Boolean(
value &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.values(value).every(isWorkspaceFunctionDefinition),
);
}
function metadataFor(
name: string,
definition: WorkspaceFunctionDefinition,
): WorkspaceFunctionMetadata {
if (typeof definition === "function") {
return {
name,
description: "",
sideEffects: "read-only",
};
}
return {
name,
description: definition.description ?? "",
...(definition.inputSchema !== undefined ? { inputSchema: definition.inputSchema } : {}),
...(definition.outputSchema !== undefined ? { outputSchema: definition.outputSchema } : {}),
...(definition.examples !== undefined ? { examples: definition.examples } : {}),
...(definition.tags !== undefined ? { tags: definition.tags } : {}),
sideEffects: definition.sideEffects ?? "read-only",
...(definition.timeoutMs !== undefined ? { timeoutMs: definition.timeoutMs } : {}),
};
}
function handlerFor(definition: WorkspaceFunctionDefinition): WorkspaceFunctionHandler {
return typeof definition === "function" ? definition : definition.handler;
}
function isWorkspaceFunctionDefinition(
value: unknown,
): value is WorkspaceFunctionDefinition {
if (typeof value === "function") {
return true;
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return false;
}
const input = value as { handler?: unknown; sideEffects?: unknown; tags?: unknown };
return typeof input.handler === "function" &&
(input.sideEffects === undefined || isSideEffects(input.sideEffects)) &&
(input.tags === undefined ||
(Array.isArray(input.tags) && input.tags.every((tag) => typeof tag === "string")));
}
function isSideEffects(value: unknown): value is WorkspaceFunctionSideEffects {
return value === "none" ||
value === "read-only" ||
value === "writes-local" ||
value === "external-write";
}
function isFunctionName(value: string): boolean {
return /^[A-Za-z_$][A-Za-z0-9_$.-]*$/.test(value);
}
function describeParams(value: unknown): WorkspaceFunctionsDescribeParams {
const input = record(value);
const name = stringValue(input.name);
if (!name) {
throw new Error("functions.describe requires name");
}
return { name };
}
function callParams(value: unknown): WorkspaceFunctionsCallParams {
const input = record(value);
const name = stringValue(input.name);
if (!name) {
throw new Error("functions.call requires name");
}
return { name, params: input.params };
}
function jsonRoundTrip(value: unknown, message: string): unknown {
if (value === undefined) {
throw new Error(message);
}
try {
const text = JSON.stringify(value);
if (text === undefined) {
throw new Error("JSON.stringify returned undefined");
}
return JSON.parse(text) as unknown;
} catch (error) {
throw new Error(`${message}: ${errorMessage(error)}`);
}
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function stringValue(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}
async function exists(filePath: string): Promise<boolean> {
try {
await access(filePath);
return true;
} catch {
return false;
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -27,6 +27,27 @@ export {
CodexWebSocketTransport,
type CodexWebSocketTransportOptions,
} from "./app-server/websocket-transport.ts";
export {
WORKSPACE_FUNCTIONS_CALL_METHOD,
WORKSPACE_FUNCTIONS_DESCRIBE_METHOD,
WORKSPACE_FUNCTIONS_LIST_METHOD,
WorkspaceFunctionRuntime,
createWorkspaceFunctionMethods,
defineFunctions,
findFunctionsManifest,
type WorkspaceFunctionContext,
type WorkspaceFunctionDefinition,
type WorkspaceFunctionDefinitions,
type WorkspaceFunctionHandler,
type WorkspaceFunctionMetadata,
type WorkspaceFunctionRuntimeOptions,
type WorkspaceFunctionSideEffects,
type WorkspaceFunctionsCallParams,
type WorkspaceFunctionsCallResponse,
type WorkspaceFunctionsDescribeParams,
type WorkspaceFunctionsDescribeResponse,
type WorkspaceFunctionsListResponse,
} from "./functions.ts";
export {
createSshRemoteAgentPlan,
createSshRemoteAgentTransport,

View file

@ -0,0 +1,219 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { Plugin } from "vite";
import { CodexWebSocketTransport } from "./app-server/websocket-transport.ts";
import type { CodexWorkspaceBackendTransport } from "./workspace-backend/client.ts";
import {
WORKSPACE_BACKEND_INITIALIZE_METHOD,
type WorkspaceBackendInitializeResponse,
} from "./workspace-backend/index.ts";
import {
WORKSPACE_FUNCTIONS_CALL_METHOD,
WORKSPACE_FUNCTIONS_DESCRIBE_METHOD,
WORKSPACE_FUNCTIONS_LIST_METHOD,
type WorkspaceFunctionsCallResponse,
type WorkspaceFunctionsDescribeResponse,
type WorkspaceFunctionsListResponse,
} from "./functions.ts";
import {
createSshRemoteAgentTransport,
hasSshRemote,
type SshRemoteProviderOptions,
} from "./cli/remote-provider.ts";
export type CodexFlowsRemoteVitePluginOptions = Partial<SshRemoteProviderOptions> & {
ssh?: string;
sshTarget?: string;
workspaceUrl?: string;
basePath?: string;
transport?: CodexWorkspaceBackendTransport;
};
type WorkspaceRequester = {
request<T = unknown>(method: string, params?: unknown): Promise<T>;
close(): void;
};
export function codexFlowsRemote(
options: CodexFlowsRemoteVitePluginOptions = {},
): Plugin {
const basePath = normalizeBasePath(options.basePath ?? "/__codex_flows");
let requester: WorkspaceRequester | undefined;
const getRequester = (): WorkspaceRequester => {
if (requester) {
return requester;
}
requester = createWorkspaceRequester(options);
return requester;
};
return {
name: "codex-flows-remote",
configureServer(server) {
server.middlewares.use(async (request, response, next) => {
if (!request.url) {
next();
return;
}
const url = new URL(request.url, "http://codex-flows.local");
if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`)) {
next();
return;
}
try {
await handleCodexFlowsRequest(
getRequester(),
basePath,
request,
response,
);
} catch (error) {
writeJson(response, 500, { error: errorMessage(error) });
}
});
server.httpServer?.once("close", () => {
requester?.close();
requester = undefined;
});
},
};
}
function createWorkspaceRequester(
options: CodexFlowsRemoteVitePluginOptions,
): WorkspaceRequester {
const timeoutMs = options.timeoutMs ?? 90_000;
const transport = options.transport ?? (hasSshRemote({
sshTarget: options.sshTarget ?? options.ssh,
env: options.env,
})
? createSshRemoteAgentTransport({
...options,
sshTarget: options.sshTarget ?? options.ssh,
timeoutMs,
})
: new CodexWebSocketTransport({
url: options.workspaceUrl ?? "ws://127.0.0.1:3586",
requestTimeoutMs: timeoutMs,
}));
let initialized: Promise<WorkspaceBackendInitializeResponse> | undefined;
const initialize = async () => {
transport.start();
initialized ??= transport.request<WorkspaceBackendInitializeResponse>(
WORKSPACE_BACKEND_INITIALIZE_METHOD,
{
clientInfo: {
name: "codex-flows-vite",
title: "Codex Flows Vite Plugin",
version: "0.1.0",
},
capabilities: {
appServerPassThrough: true,
},
},
);
await initialized;
};
return {
request: async (method, params) => {
await initialize();
return await transport.request(method, params);
},
close: () => {
transport.close();
initialized = undefined;
},
};
}
async function handleCodexFlowsRequest(
requester: WorkspaceRequester,
basePath: string,
request: IncomingMessage,
response: ServerResponse,
): Promise<void> {
const url = new URL(request.url ?? "/", "http://codex-flows.local");
const path = url.pathname.slice(basePath.length) || "/";
if (request.method === "GET" && path === "/status") {
let remoteAgent: unknown = null;
try {
remoteAgent = await requester.request("remoteAgent/status", {});
} catch {
remoteAgent = null;
}
writeJson(response, 200, { ok: true, remoteAgent });
return;
}
if (request.method === "GET" && path === "/functions") {
writeJson(
response,
200,
await requester.request<WorkspaceFunctionsListResponse>(
WORKSPACE_FUNCTIONS_LIST_METHOD,
{},
),
);
return;
}
const match = path.match(/^\/functions\/([^/]+)$/);
if (match?.[1] && request.method === "GET") {
writeJson(
response,
200,
await requester.request<WorkspaceFunctionsDescribeResponse>(
WORKSPACE_FUNCTIONS_DESCRIBE_METHOD,
{ name: decodeURIComponent(match[1]) },
),
);
return;
}
if (match?.[1] && request.method === "POST") {
const body = await readJsonBody(request);
writeJson(
response,
200,
await requester.request<WorkspaceFunctionsCallResponse>(
WORKSPACE_FUNCTIONS_CALL_METHOD,
{
name: decodeURIComponent(match[1]),
params: record(body).params,
},
),
);
return;
}
writeJson(response, 404, { error: "Unknown Codex Flows endpoint" });
}
async function readJsonBody(request: IncomingMessage): Promise<unknown> {
let body = "";
for await (const chunk of request) {
body += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
if (body.length > 1_000_000) {
throw new Error("Request body is too large");
}
}
if (!body.trim()) {
return {};
}
return JSON.parse(body) as unknown;
}
function writeJson(response: ServerResponse, status: number, value: unknown): void {
response.statusCode = status;
response.setHeader("content-type", "application/json; charset=utf-8");
response.end(`${JSON.stringify(value)}\n`);
}
function normalizeBasePath(value: string): string {
const path = value.startsWith("/") ? value : `/${value}`;
return path.replace(/\/+$/, "") || "/__codex_flows";
}
function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

View file

@ -1,3 +1,15 @@
export {
WORKSPACE_FUNCTIONS_CALL_METHOD,
WORKSPACE_FUNCTIONS_DESCRIBE_METHOD,
WORKSPACE_FUNCTIONS_LIST_METHOD,
createWorkspaceFunctionMethods,
type WorkspaceFunctionMetadata,
type WorkspaceFunctionsCallParams,
type WorkspaceFunctionsCallResponse,
type WorkspaceFunctionsDescribeParams,
type WorkspaceFunctionsDescribeResponse,
type WorkspaceFunctionsListResponse,
} from "../functions.ts";
export {
CodexWorkspaceBackendClient,
type CodexWorkspaceBackendClientOptions,

View file

@ -96,6 +96,7 @@ export type WorkspaceBackendEventParams = {
export const workspaceBackendOwnedMethodPrefixes = [
"delegation.",
"functions.",
"workbench.",
] as const;

View file

@ -0,0 +1,52 @@
import { describe, expect, test } from "vite-plus/test";
import { createCodexFlowsBrowserClient } from "../src/browser.ts";
describe("Codex Flows browser client", () => {
test("calls function endpoints and unwraps responses", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
const client = createCodexFlowsBrowserClient({
basePath: "/bridge",
fetch: (async (url, init) => {
calls.push({ url: String(url), init });
if (String(url) === "/bridge/functions") {
return jsonResponse({ functions: [{ name: "snapshot", description: "", sideEffects: "read-only" }] });
}
if (String(url) === "/bridge/functions/snapshot" && !init) {
return jsonResponse({ function: { name: "snapshot", description: "", sideEffects: "read-only" } });
}
if (String(url) === "/bridge/functions/snapshot" && init?.method === "POST") {
return jsonResponse({ result: { ok: true } });
}
return jsonResponse({ error: "not found" }, 404);
}) as typeof fetch,
});
await expect(client.functions.list()).resolves.toEqual([
{ name: "snapshot", description: "", sideEffects: "read-only" },
]);
await expect(client.functions.describe("snapshot")).resolves.toEqual({
name: "snapshot",
description: "",
sideEffects: "read-only",
});
await expect(client.functions.call("snapshot", { include: "cash" })).resolves.toEqual({
ok: true,
});
expect(calls.at(-1)?.init?.body).toBe("{\"params\":{\"include\":\"cash\"}}");
});
test("propagates endpoint errors", async () => {
const client = createCodexFlowsBrowserClient({
fetch: (async () => jsonResponse({ error: "boom" }, 500)) as typeof fetch,
});
await expect(client.functions.list()).rejects.toThrow("boom");
});
});
function jsonResponse(value: unknown, status = 200): Response {
return new Response(JSON.stringify(value), {
status,
headers: { "content-type": "application/json" },
});
}

View file

@ -1,6 +1,6 @@
import { describe, expect, test } from "vite-plus/test";
import { spawn } from "node:child_process";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
@ -322,6 +322,93 @@ describe("codex-flows CLI args", () => {
});
});
test("parses workspace function commands", () => {
expect(parseArgs(["functions", "list", "--json"], {})).toMatchObject({
type: "functions-list",
json: true,
});
expect(parseArgs(["functions", "describe", "portfolioSnapshot", "--json"], {}))
.toMatchObject({
type: "functions-describe",
name: "portfolioSnapshot",
json: true,
});
expect(parseArgs([
"--ssh",
"devbox",
"--cwd",
"/repo",
"functions",
"call",
"portfolioSnapshot",
"--params-json",
"{\"account\":\"demo\"}",
"--json",
], {})).toMatchObject({
type: "functions-call",
name: "portfolioSnapshot",
paramsText: "{\"account\":\"demo\"}",
sshTarget: "devbox",
cwd: "/repo",
json: true,
});
});
test("executes SSH workspace function commands", async () => {
const fakeSsh = await createFunctionFakeSshCommand();
const env = { CODEX_FLOWS_SSH_COMMAND: fakeSsh };
const list = await runCli([
"--ssh",
"devbox",
"--cwd",
"/repo",
"functions",
"list",
"--json",
], env);
expect(list.exitCode).toBe(0);
expect(JSON.parse(list.stdout)).toEqual({
functions: [{
name: "portfolioSnapshot",
description: "Read portfolio.",
sideEffects: "read-only",
}],
});
const describe = await runCli([
"--ssh",
"devbox",
"--cwd",
"/repo",
"functions",
"describe",
"portfolioSnapshot",
"--json",
], env);
expect(describe.exitCode).toBe(0);
expect(JSON.parse(describe.stdout)).toMatchObject({
function: { name: "portfolioSnapshot" },
});
const call = await runCli([
"--ssh",
"devbox",
"--cwd",
"/repo",
"functions",
"call",
"portfolioSnapshot",
"--params-json",
"{\"account\":\"demo\"}",
"--json",
], env);
expect(call.exitCode).toBe(0);
expect(JSON.parse(call.stdout)).toEqual({
result: { account: "demo", equity: 456 },
});
});
test("parses app-server pass-through through the workspace backend", () => {
expect(parseArgs([
"workspace",
@ -599,6 +686,86 @@ async function runCli(
return { exitCode: exitCode ?? 1, stdout, stderr };
}
async function createFunctionFakeSshCommand(): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), "codex-functions-cli-"));
const command = path.join(dir, "ssh.mjs");
await writeFile(command, functionFakeSshScript());
await chmod(command, 0o755);
return command;
}
function functionFakeSshScript(): string {
return `#!/usr/bin/env node
import { stdin, stdout } from "node:process";
process.on("SIGTERM", () => process.exit(0));
let buffer = "";
stdin.setEncoding("utf8");
stdin.on("data", (chunk) => {
buffer += chunk;
let newline = buffer.indexOf("\\n");
while (newline !== -1) {
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
if (line) handle(line);
newline = buffer.indexOf("\\n");
}
});
function handle(line) {
const message = JSON.parse(line);
stdout.write(JSON.stringify({
jsonrpc: "2.0",
id: message.id,
result: resultFor(message.method, message.params),
}) + "\\n");
}
function resultFor(method, params) {
if (method === "workspace.initialize") {
return {
ok: true,
serverInfo: { name: "fake-remote-agent", version: "0.1.0" },
capabilities: {
appServerPassThrough: true,
workspaceMethods: ["functions.list", "functions.describe", "functions.call"],
},
};
}
if (method === "functions.list") {
return {
functions: [{
name: "portfolioSnapshot",
description: "Read portfolio.",
sideEffects: "read-only",
}],
};
}
if (method === "functions.describe") {
return {
function: {
name: params.name,
description: "Read portfolio.",
sideEffects: "read-only",
},
};
}
if (method === "functions.call") {
return {
result: {
account: params.params.account,
equity: 456,
},
};
}
return {};
}
setInterval(() => {}, 1_000);
`;
}
async function collectText(stream: NodeJS.ReadableStream | null): Promise<string> {
let output = "";
if (!stream) {

View file

@ -0,0 +1,119 @@
import { describe, expect, test } from "vite-plus/test";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
WorkspaceFunctionRuntime,
createWorkspaceFunctionMethods,
defineFunctions,
findFunctionsManifest,
} from "../src/functions.ts";
describe("workspace functions", () => {
test("defineFunctions returns definitions unchanged", () => {
const definitions = defineFunctions({
ping: () => ({ ok: true }),
});
expect(Object.keys(definitions)).toEqual(["ping"]);
});
test("loads TypeScript manifest metadata and calls handlers", async () => {
const root = await createWorkspace(`export default {
greet: {
description: "Greet a person.",
inputSchema: { type: "object", properties: { name: { type: "string" } } },
outputSchema: { type: "object" },
tags: ["demo"],
handler: async (params) => ({ message: "hello " + params.name })
},
snapshot: async () => ({ ok: true })
};`);
const runtime = new WorkspaceFunctionRuntime({ cwd: root });
await expect(findFunctionsManifest(root)).resolves.toMatch(/functions\.ts$/);
await expect(runtime.list()).resolves.toEqual({
functions: [
{
name: "greet",
description: "Greet a person.",
inputSchema: { type: "object", properties: { name: { type: "string" } } },
outputSchema: { type: "object" },
tags: ["demo"],
sideEffects: "read-only",
},
{
name: "snapshot",
description: "",
sideEffects: "read-only",
},
],
});
await expect(runtime.describe("greet")).resolves.toMatchObject({
function: { name: "greet", sideEffects: "read-only" },
});
await expect(runtime.call("greet", { name: "Ada" })).resolves.toEqual({
result: { message: "hello Ada" },
});
});
test("reports missing functions and handler failures", async () => {
const root = await createWorkspace(`export default {
fail: {
description: "Always fails.",
handler: async () => { throw new Error("boom"); }
}
};`);
const runtime = new WorkspaceFunctionRuntime({ cwd: root });
await expect(runtime.describe("missing")).rejects.toThrow("Workspace function not found: missing");
await expect(runtime.call("fail")).rejects.toThrow("boom");
});
test("rejects non JSON-serializable results", async () => {
const root = await createWorkspace(`export default {
circular: () => {
const value = {};
value.self = value;
return value;
},
empty: () => undefined
};`);
const runtime = new WorkspaceFunctionRuntime({ cwd: root });
await expect(runtime.call("circular")).rejects.toThrow("Workspace function returned non-JSON data: circular");
await expect(runtime.call("empty")).rejects.toThrow("Workspace function returned non-JSON data: empty");
});
test("creates workspace backend methods", async () => {
const root = await createWorkspace(`export default {
echo: {
description: "Echo params.",
handler: (params) => params
}
};`);
const methods = createWorkspaceFunctionMethods({ cwd: root });
await expect(methods["functions.list"]?.({}, rpcRequest("functions.list")))
.resolves.toMatchObject({ functions: [{ name: "echo" }] });
await expect(methods["functions.call"]?.(
{ name: "echo", params: { ok: true } },
rpcRequest("functions.call"),
)).resolves.toEqual({ result: { ok: true } });
});
});
async function createWorkspace(source: string): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), "codex-functions-"));
await mkdir(path.join(root, ".codex"), { recursive: true });
await writeFile(path.join(root, ".codex", "functions.ts"), source);
return root;
}
function rpcRequest(method: string) {
return {
jsonrpc: "2.0" as const,
id: "test",
method,
params: {},
};
}

View file

@ -109,6 +109,29 @@ describe("SSH remote provider", () => {
updatedAt: 1,
}],
});
const functions = await transport.request("functions.list", {});
expect(functions).toEqual({
functions: [{
name: "portfolioSnapshot",
description: "Read the latest portfolio snapshot.",
sideEffects: "read-only",
}],
});
const described = await transport.request("functions.describe", {
name: "portfolioSnapshot",
});
expect(described).toEqual({
function: {
name: "portfolioSnapshot",
description: "Read the latest portfolio snapshot.",
sideEffects: "read-only",
},
});
const called = await transport.request("functions.call", {
name: "portfolioSnapshot",
params: { account: "demo" },
});
expect(called).toEqual({ result: { account: "demo", equity: 123 } });
await waitForLog(fakeSsh, (entries) =>
entries.some((entry) => entry.mode === "request" &&
entry.method === "appServer.call")
@ -197,7 +220,7 @@ function resultFor(method, params) {
return {
ok: true,
serverInfo: { name: "fake-remote-agent", version: "0.1.0" },
capabilities: { appServerPassThrough: true, workspaceMethods: ["remoteAgent/status"] },
capabilities: { appServerPassThrough: true, workspaceMethods: ["remoteAgent/status", "functions.list", "functions.describe", "functions.call"] },
};
}
if (method === "appServer.call" && params.method === "thread/list") {
@ -208,6 +231,32 @@ function resultFor(method, params) {
name: "Remote thread",
updatedAt: 1,
}],
};
}
if (method === "functions.list") {
return {
functions: [{
name: "portfolioSnapshot",
description: "Read the latest portfolio snapshot.",
sideEffects: "read-only",
}],
};
}
if (method === "functions.describe") {
return {
function: {
name: params.name,
description: "Read the latest portfolio snapshot.",
sideEffects: "read-only",
},
};
}
if (method === "functions.call") {
return {
result: {
account: params.params.account,
equity: 123,
},
};
}
return {};

View file

@ -0,0 +1,110 @@
import { describe, expect, test } from "vite-plus/test";
import { createServer } from "vite";
import { CodexEventEmitter } from "../src/app-server/events.ts";
import type { CodexWorkspaceBackendTransport } from "../src/workspace-backend/client.ts";
import { codexFlowsRemote } from "../src/vite.ts";
describe("codexFlowsRemote Vite plugin", () => {
test("serves local bridge endpoints and forwards function calls", async () => {
const transport = new FakeWorkspaceTransport();
const server = await createServer({
configFile: false,
logLevel: "silent",
server: {
host: "127.0.0.1",
port: 0,
},
plugins: [codexFlowsRemote({ transport })],
});
try {
await server.listen();
const baseUrl = serverBaseUrl(server);
const status = await fetchJson(`${baseUrl}/__codex_flows/status`);
expect(status).toMatchObject({ ok: true, remoteAgent: { ok: true, cwd: "/remote" } });
const functions = await fetchJson(`${baseUrl}/__codex_flows/functions`);
expect(functions).toEqual({
functions: [{ name: "snapshot", description: "Read snapshot.", sideEffects: "read-only" }],
});
const described = await fetchJson(`${baseUrl}/__codex_flows/functions/snapshot`);
expect(described).toEqual({
function: { name: "snapshot", description: "Read snapshot.", sideEffects: "read-only" },
});
const called = await fetchJson(`${baseUrl}/__codex_flows/functions/snapshot`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ params: { id: "one" } }),
});
expect(called).toEqual({ result: { id: "one", ok: true } });
expect(transport.requests.map((request) => request.method)).toEqual([
"workspace.initialize",
"remoteAgent/status",
"functions.list",
"functions.describe",
"functions.call",
]);
} finally {
await server.close();
}
});
});
class FakeWorkspaceTransport extends CodexEventEmitter implements CodexWorkspaceBackendTransport {
readonly requestTimeoutMs = 1_000;
requests: Array<{ method: string; params?: unknown }> = [];
start(): void {}
close(): void {}
notify(): void {}
async request<T = unknown>(method: string, params?: unknown): Promise<T> {
this.requests.push({ method, params });
if (method === "workspace.initialize") {
return {
ok: true,
serverInfo: { name: "fake", version: "0.1.0" },
capabilities: {
appServerPassThrough: true,
workspaceMethods: ["functions.list", "functions.describe", "functions.call"],
},
} as T;
}
if (method === "remoteAgent/status") {
return { ok: true, cwd: "/remote" } as T;
}
if (method === "functions.list") {
return {
functions: [{ name: "snapshot", description: "Read snapshot.", sideEffects: "read-only" }],
} as T;
}
if (method === "functions.describe") {
return {
function: { name: "snapshot", description: "Read snapshot.", sideEffects: "read-only" },
} as T;
}
if (method === "functions.call") {
const input = params as { params?: { id?: string } };
return { result: { id: input.params?.id, ok: true } } as T;
}
throw new Error(`Unexpected request: ${method}`);
}
}
async function fetchJson(url: string, init?: RequestInit): Promise<unknown> {
const response = await fetch(url, init);
expect(response.ok).toBe(true);
return await response.json() as unknown;
}
function serverBaseUrl(server: Awaited<ReturnType<typeof createServer>>): string {
const address = server.httpServer?.address();
if (!address || typeof address === "string") {
throw new Error("Vite server is not listening on a TCP port");
}
return `http://127.0.0.1:${address.port}`;
}

View file

@ -1,6 +1,6 @@
{
"name": "codex-flows-remote-control",
"version": "0.133.0",
"version": "0.133.4",
"description": "Operate remote Codex workspaces from a local Codex App over SSH.",
"author": {
"name": "Peezy",
@ -35,7 +35,8 @@
"defaultPrompt": [
"Discover my Codex App remote connections and run codex-flows over SSH.",
"Check whether my remote codex-flows agent is reachable.",
"Start a Codex turn in the remote workspace."
"Start a Codex turn in the remote workspace.",
"Build a local Vite dashboard for a remote workspace over SSH."
],
"brandColor": "#0F766E"
}

View file

@ -0,0 +1,101 @@
---
name: remote-dashboard-builder
description: Use when building local Vite dashboards that inspect or operate remote Codex workspaces over SSH through codex-flows functions, without exposing remote HTTP ports.
---
# Remote Dashboard Builder
Use this skill when a user wants a browser dashboard for a remote Codex
workspace, especially when human verification needs a local browser but the
workspace runs over SSH.
## Direction
- Build the dashboard as a local Vite app.
- Do not start human-facing preview servers on the remote host.
- Use `@peezy.tech/codex-flows/vite` as the local SSH bridge.
- Use `@peezy.tech/codex-flows/browser` from dashboard code.
- Use `.codex/functions.ts` in the remote workspace for active data or actions.
## Discovery Flow
Before designing the dashboard, inspect what the remote workspace already
exposes:
```bash
codex-flows --ssh <target> --cwd <remote-workspace> functions list --json
codex-flows --ssh <target> --cwd <remote-workspace> functions describe <name> --json
```
Probe only read-only or no-side-effect functions with small sample inputs:
```bash
codex-flows --ssh <target> --cwd <remote-workspace> functions call <name> --params-json '{"sample":true}' --json
```
Do not casually call functions that declare `sideEffects: "writes-local"` or
`sideEffects: "external-write"`. Ask the user before calling functions that can
mutate local workspace state, external systems, money, deployments, accounts,
or production data.
## Vite Setup
Use the Vite plugin in the local dashboard:
```ts
import { codexFlowsRemote } from "@peezy.tech/codex-flows/vite";
export default {
plugins: [
codexFlowsRemote({
ssh: process.env.CODEX_FLOWS_REMOTE_SSH_TARGET,
cwd: process.env.CODEX_FLOWS_REMOTE_CWD,
}),
],
};
```
Dashboard code calls the local bridge:
```ts
import { codexFlows } from "@peezy.tech/codex-flows/browser";
const functions = await codexFlows.functions.list();
const snapshot = await codexFlows.functions.call("portfolioSnapshot");
```
The browser talks only to the local Vite server under `/__codex_flows`. The
Vite plugin owns the SSH connection and forwards requests to the remote-agent.
## Remote Workspace Functions
Add narrow, named functions only when the dashboard needs active data that is
not already exposed:
```ts
import { defineFunctions } from "@peezy.tech/codex-flows/functions";
export default defineFunctions({
portfolioSnapshot: {
description: "Read the latest portfolio snapshot.",
sideEffects: "read-only",
handler: async () => {
return { positions: [], cash: 0 };
},
},
});
```
Keep functions JSON-in and JSON-out. Return plain objects, arrays, strings,
numbers, booleans, or null. Avoid returning class instances, streams, circular
objects, functions, BigInts, secrets, raw private keys, or broad filesystem
contents.
## Safety Rules
- Never expose arbitrary shell execution to the browser.
- Prefer small read-only functions for discovery and dashboard refresh.
- Use descriptive names, descriptions, schemas, examples, and tags when useful.
- Use `sideEffects: "external-write"` for actions that touch external systems.
- Build the dashboard from real function metadata and returned shapes, not
guesses.

View file

@ -0,0 +1,101 @@
---
name: remote-dashboard-builder
description: Use when building local Vite dashboards that inspect or operate remote Codex workspaces over SSH through codex-flows functions, without exposing remote HTTP ports.
---
# Remote Dashboard Builder
Use this skill when a user wants a browser dashboard for a remote Codex
workspace, especially when human verification needs a local browser but the
workspace runs over SSH.
## Direction
- Build the dashboard as a local Vite app.
- Do not start human-facing preview servers on the remote host.
- Use `@peezy.tech/codex-flows/vite` as the local SSH bridge.
- Use `@peezy.tech/codex-flows/browser` from dashboard code.
- Use `.codex/functions.ts` in the remote workspace for active data or actions.
## Discovery Flow
Before designing the dashboard, inspect what the remote workspace already
exposes:
```bash
codex-flows --ssh <target> --cwd <remote-workspace> functions list --json
codex-flows --ssh <target> --cwd <remote-workspace> functions describe <name> --json
```
Probe only read-only or no-side-effect functions with small sample inputs:
```bash
codex-flows --ssh <target> --cwd <remote-workspace> functions call <name> --params-json '{"sample":true}' --json
```
Do not casually call functions that declare `sideEffects: "writes-local"` or
`sideEffects: "external-write"`. Ask the user before calling functions that can
mutate local workspace state, external systems, money, deployments, accounts,
or production data.
## Vite Setup
Use the Vite plugin in the local dashboard:
```ts
import { codexFlowsRemote } from "@peezy.tech/codex-flows/vite";
export default {
plugins: [
codexFlowsRemote({
ssh: process.env.CODEX_FLOWS_REMOTE_SSH_TARGET,
cwd: process.env.CODEX_FLOWS_REMOTE_CWD,
}),
],
};
```
Dashboard code calls the local bridge:
```ts
import { codexFlows } from "@peezy.tech/codex-flows/browser";
const functions = await codexFlows.functions.list();
const snapshot = await codexFlows.functions.call("portfolioSnapshot");
```
The browser talks only to the local Vite server under `/__codex_flows`. The
Vite plugin owns the SSH connection and forwards requests to the remote-agent.
## Remote Workspace Functions
Add narrow, named functions only when the dashboard needs active data that is
not already exposed:
```ts
import { defineFunctions } from "@peezy.tech/codex-flows/functions";
export default defineFunctions({
portfolioSnapshot: {
description: "Read the latest portfolio snapshot.",
sideEffects: "read-only",
handler: async () => {
return { positions: [], cash: 0 };
},
},
});
```
Keep functions JSON-in and JSON-out. Return plain objects, arrays, strings,
numbers, booleans, or null. Avoid returning class instances, streams, circular
objects, functions, BigInts, secrets, raw private keys, or broad filesystem
contents.
## Safety Rules
- Never expose arbitrary shell execution to the browser.
- Prefer small read-only functions for discovery and dashboard refresh.
- Use descriptive names, descriptions, schemas, examples, and tags when useful.
- Use `sideEffects: "external-write"` for actions that touch external systems.
- Build the dashboard from real function metadata and returned shapes, not
guesses.